mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-10-13 02:55:28 +02:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76f8017ea7
|
||
|
|
7d3487312f
|
||
|
|
defce7d2d3
|
||
|
|
74906ee6a3
|
||
|
|
adb60ca7e5
|
||
|
|
8ec9dd4f20 | ||
|
|
b3c6403635 | ||
|
|
af24ec35df | ||
|
|
b24646153c | ||
|
|
1212cbf559 | ||
|
|
66abd5f1a7 | ||
|
|
c3b14f7567 | ||
|
|
3806a35838 | ||
|
|
2a251f78f8 | ||
|
|
4bc12af856 | ||
|
|
27593b1391 | ||
|
|
c2b2ad2027 | ||
|
|
fe35fb63d1 | ||
|
|
eb2f9c43b1 | ||
|
|
ce53343d10 | ||
|
|
87f60c56f5 | ||
|
|
43a83e4004 | ||
|
|
d966bd1aa4 | ||
|
|
144125d355 | ||
|
|
9ea51458c3 | ||
|
|
9b31fcda9e | ||
|
|
6e5c6aa086 | ||
|
|
6ba44a75eb | ||
|
|
5890b1d3b9 | ||
|
|
24ebea1791 | ||
|
|
94a7924bed
|
||
|
|
90b9d557d8
|
||
|
|
33ff267644
|
||
|
|
33673d661b
|
||
|
|
41f3f5c4e2 | ||
|
|
d644094999
|
||
|
|
71d1429048
|
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://dprint.dev/schemas/v0.json",
|
||||||
|
"extends": "https://github.com/kjanat/kjanat/raw/8cb7b67/configs/dprint.remote.json",
|
||||||
|
"excludes": ["**/node_modules", "**/.venv", "**/bun.lock*"],
|
||||||
|
"plugins": [
|
||||||
|
"npm:@dprint/prettier@0.70.0/plugin.json@97c0dccf54ed5b7343e7a3f6418e77bd80afcd2b74beed3a632689d47e392ddc",
|
||||||
|
"npm:@dprint/exec@0.7.3/plugin.json@704701df449dd7e942a71144773778ac529d68c2e4657bfc236d393b898b9a67",
|
||||||
|
],
|
||||||
|
"markdown": { "associations": ["!README.md"] },
|
||||||
|
"prettier": { "associations": ["README.md"], "useTabs": false },
|
||||||
|
"exec": {
|
||||||
|
"cwd": "${configDir}",
|
||||||
|
"commands": [{
|
||||||
|
"command": "bunx tombi format - --stdin-filename {{file_path}}",
|
||||||
|
"exts": ["toml"],
|
||||||
|
"cacheKeyFiles": ["tombi.toml"],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}
|
||||||
+10
-10
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
|
|||||||
Examples of behavior that contributes to a positive environment for our
|
Examples of behavior that contributes to a positive environment for our
|
||||||
community include:
|
community include:
|
||||||
|
|
||||||
- Demonstrating empathy and kindness toward other people
|
- Demonstrating empathy and kindness toward other people
|
||||||
- Being respectful of differing opinions, viewpoints, and experiences
|
- Being respectful of differing opinions, viewpoints, and experiences
|
||||||
- Giving and gracefully accepting constructive feedback
|
- Giving and gracefully accepting constructive feedback
|
||||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
and learning from the experience
|
and learning from the experience
|
||||||
- Focusing on what is best not just for us as individuals, but for the
|
- Focusing on what is best not just for us as individuals, but for the
|
||||||
overall community
|
overall community
|
||||||
|
|
||||||
Examples of unacceptable behavior include:
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
- The use of sexualized language or imagery, and sexual attention or
|
- The use of sexualized language or imagery, and sexual attention or
|
||||||
advances of any kind
|
advances of any kind
|
||||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
- Public or private harassment
|
- Public or private harassment
|
||||||
- Publishing others' private information, such as a physical or email
|
- Publishing others' private information, such as a physical or email
|
||||||
address, without their explicit permission
|
address, without their explicit permission
|
||||||
- Other conduct which could reasonably be considered inappropriate in a
|
- Other conduct which could reasonably be considered inappropriate in a
|
||||||
professional setting
|
professional setting
|
||||||
|
|
||||||
## Enforcement Responsibilities
|
## Enforcement Responsibilities
|
||||||
|
|||||||
+65
-65
@@ -12,73 +12,73 @@ This project and everyone participating in it is governed by our Code of Conduct
|
|||||||
|
|
||||||
Before creating bug reports, please check existing issues as you might find that the issue has already been reported. When creating a bug report, include as many details as possible:
|
Before creating bug reports, please check existing issues as you might find that the issue has already been reported. When creating a bug report, include as many details as possible:
|
||||||
|
|
||||||
- Use the bug report template
|
- Use the bug report template
|
||||||
- Include sample Articulate Rise content that reproduces the issue
|
- Include sample Articulate Rise content that reproduces the issue
|
||||||
- Provide your environment details (OS, Go version, etc.)
|
- Provide your environment details (OS, Go version, etc.)
|
||||||
- Include error messages and stack traces
|
- Include error messages and stack traces
|
||||||
|
|
||||||
### Suggesting Enhancements
|
### Suggesting Enhancements
|
||||||
|
|
||||||
Enhancement suggestions are welcome! Please use the feature request template and include:
|
Enhancement suggestions are welcome! Please use the feature request template and include:
|
||||||
|
|
||||||
- A clear description of the enhancement
|
- A clear description of the enhancement
|
||||||
- Your use case and why this would be valuable
|
- Your use case and why this would be valuable
|
||||||
- Any implementation ideas you might have
|
- Any implementation ideas you might have
|
||||||
|
|
||||||
### Pull Requests
|
### Pull Requests
|
||||||
|
|
||||||
1. **Fork the repository** and create your branch from `master`
|
1. **Fork the repository** and create your branch from `master`
|
||||||
2. **Make your changes** following our coding standards
|
2. **Make your changes** following our coding standards
|
||||||
3. **Add tests** for any new functionality
|
3. **Add tests** for any new functionality
|
||||||
4. **Ensure all tests pass** by running `go test ./...`
|
4. **Ensure all tests pass** by running `go test ./...`
|
||||||
5. **Run `go fmt`** to format your code
|
5. **Run `go fmt`** to format your code
|
||||||
6. **Run `go vet`** to check for common issues
|
6. **Run `go vet`** to check for common issues
|
||||||
7. **Update documentation** if needed
|
7. **Update documentation** if needed
|
||||||
8. **Create a pull request** with a clear title and description
|
8. **Create a pull request** with a clear title and description
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
1. **Prerequisites:**
|
1. **Prerequisites:**
|
||||||
|
|
||||||
- Go 1.21 or later
|
- Go 1.21 or later
|
||||||
- Git
|
- Git
|
||||||
|
|
||||||
2. **Clone and setup:**
|
2. **Clone and setup:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-username/articulate-parser.git
|
git clone https://github.com/your-username/articulate-parser.git
|
||||||
cd articulate-parser
|
cd articulate-parser
|
||||||
go mod download
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Run tests:**
|
3. **Run tests:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test -v ./...
|
go test -v ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Build:**
|
4. **Build:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build main.go
|
go build main.go
|
||||||
```
|
```
|
||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
### Go Style Guide
|
### Go Style Guide
|
||||||
|
|
||||||
- Follow the [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments)
|
- Follow the [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments)
|
||||||
- Use `gofmt` to format your code
|
- Use `gofmt` to format your code
|
||||||
- Use meaningful variable and function names
|
- Use meaningful variable and function names
|
||||||
- Add comments for exported functions and types
|
- Add comments for exported functions and types
|
||||||
- Keep functions focused and small
|
- Keep functions focused and small
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- Write tests for new functionality
|
- Write tests for new functionality
|
||||||
- Use table-driven tests where appropriate
|
- Use table-driven tests where appropriate
|
||||||
- Aim for good test coverage
|
- Aim for good test coverage
|
||||||
- Test error cases and edge conditions
|
- Test error cases and edge conditions
|
||||||
|
|
||||||
### Commit Messages
|
### Commit Messages
|
||||||
|
|
||||||
@@ -112,19 +112,19 @@ articulate-parser/
|
|||||||
|
|
||||||
### New Content Types
|
### New Content Types
|
||||||
|
|
||||||
1. Add the content type definition to `types/`
|
1. Add the content type definition to `types/`
|
||||||
2. Implement parsing logic in `parser/`
|
2. Implement parsing logic in `parser/`
|
||||||
3. Add export handling in `exporters/`
|
3. Add export handling in `exporters/`
|
||||||
4. Write comprehensive tests
|
4. Write comprehensive tests
|
||||||
5. Update documentation
|
5. Update documentation
|
||||||
|
|
||||||
### New Export Formats
|
### New Export Formats
|
||||||
|
|
||||||
1. Create a new exporter in `exporters/`
|
1. Create a new exporter in `exporters/`
|
||||||
2. Implement the `Exporter` interface
|
2. Implement the `Exporter` interface
|
||||||
3. Add CLI support in `main.go`
|
3. Add CLI support in `main.go`
|
||||||
4. Add tests with sample output
|
4. Add tests with sample output
|
||||||
5. Update README with usage examples
|
5. Update README with usage examples
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -146,31 +146,31 @@ go test -run TestSpecificFunction ./...
|
|||||||
|
|
||||||
### Test Data
|
### Test Data
|
||||||
|
|
||||||
- Add sample Articulate Rise JSON files to `tests/data/`
|
- Add sample Articulate Rise JSON files to `tests/data/`
|
||||||
- Include both simple and complex content examples
|
- Include both simple and complex content examples
|
||||||
- Test edge cases and error conditions
|
- Test edge cases and error conditions
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- Update the README for user-facing changes
|
- Update the README for user-facing changes
|
||||||
- Add inline code comments for complex logic
|
- Add inline code comments for complex logic
|
||||||
- Update examples when adding new features
|
- Update examples when adding new features
|
||||||
- Keep the feature list current
|
- Keep the feature list current
|
||||||
|
|
||||||
## Release Process
|
## Release Process
|
||||||
|
|
||||||
Releases are handled by maintainers:
|
Releases are handled by maintainers:
|
||||||
|
|
||||||
1. Version bumping follows semantic versioning
|
1. Version bumping follows semantic versioning
|
||||||
2. Releases are created from the `master` branch
|
2. Releases are created from the `master` branch
|
||||||
3. GitHub Actions automatically builds and publishes releases
|
3. GitHub Actions automatically builds and publishes releases
|
||||||
4. Release notes are auto-generated from commits
|
4. Release notes are auto-generated from commits
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
- Open a discussion for general questions
|
- Open a discussion for general questions
|
||||||
- Use the question issue template for specific help
|
- Use the question issue template for specific help
|
||||||
- Check existing issues and documentation first
|
- Check existing issues and documentation first
|
||||||
|
|
||||||
## Recognition
|
## Recognition
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Create a report to help us improve
|
description: Create a report to help us improve
|
||||||
title: '[BUG] '
|
title: "[BUG] "
|
||||||
labels: ['bug', 'triage']
|
labels: ["bug", "triage"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -5,31 +5,34 @@
|
|||||||
## Related Issue
|
## Related Issue
|
||||||
|
|
||||||
<!-- Link to the issue this PR addresses using the syntax: Fixes #issue_number -->
|
<!-- Link to the issue this PR addresses using the syntax: Fixes #issue_number -->
|
||||||
|
|
||||||
Fixes #
|
Fixes #
|
||||||
|
|
||||||
## Type of Change
|
## Type of Change
|
||||||
|
|
||||||
<!-- Mark the appropriate option with an "x" -->
|
<!-- Mark the appropriate option with an "x" -->
|
||||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
|
||||||
- [ ] New feature (non-breaking change which adds functionality)
|
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
- [ ] New feature (non-breaking change which adds functionality)
|
||||||
- [ ] Documentation update
|
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||||
- [ ] Performance improvement
|
- [ ] Documentation update
|
||||||
- [ ] Code refactoring (no functional changes)
|
- [ ] Performance improvement
|
||||||
- [ ] Test updates
|
- [ ] Code refactoring (no functional changes)
|
||||||
|
- [ ] Test updates
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
<!-- Mark the items you've completed with an "x" -->
|
<!-- Mark the items you've completed with an "x" -->
|
||||||
- [ ] My code follows the style guidelines of this project
|
|
||||||
- [ ] I have performed a self-review of my code
|
- [ ] My code follows the style guidelines of this project
|
||||||
- [ ] I have added comments to complex logic
|
- [ ] I have performed a self-review of my code
|
||||||
- [ ] I have updated the documentation
|
- [ ] I have added comments to complex logic
|
||||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
- [ ] I have updated the documentation
|
||||||
- [ ] New and existing unit tests pass locally with my changes
|
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||||
- [ ] I have checked for potential breaking changes
|
- [ ] New and existing unit tests pass locally with my changes
|
||||||
- [ ] No new warnings are generated
|
- [ ] I have checked for potential breaking changes
|
||||||
- [ ] The commit message follows our guidelines
|
- [ ] No new warnings are generated
|
||||||
|
- [ ] The commit message follows our guidelines
|
||||||
|
|
||||||
## Screenshots (if appropriate)
|
## Screenshots (if appropriate)
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ Fixes #
|
|||||||
## Testing Instructions
|
## Testing Instructions
|
||||||
|
|
||||||
<!-- Provide steps to test the changes, if applicable -->
|
<!-- Provide steps to test the changes, if applicable -->
|
||||||
|
|
||||||
1.
|
1.
|
||||||
2.
|
2.
|
||||||
3.
|
3.
|
||||||
|
|||||||
+16
-16
@@ -13,32 +13,32 @@ Currently, the following versions of Articulate Rise Parser are supported with s
|
|||||||
|
|
||||||
We take the security of Articulate Rise Parser seriously. If you believe you have found a security vulnerability, please follow these steps:
|
We take the security of Articulate Rise Parser seriously. If you believe you have found a security vulnerability, please follow these steps:
|
||||||
|
|
||||||
1. **Do not disclose the vulnerability publicly** - Please do not create a public GitHub issue for security vulnerabilities.
|
1. **Do not disclose the vulnerability publicly** - Please do not create a public GitHub issue for security vulnerabilities.
|
||||||
2. **Email the details to [security+articulate-parser@kjanat.com]** - Include as much information as possible about the vulnerability.
|
2. **Email the details to [security+articulate-parser@kjanat.com]** - Include as much information as possible about the vulnerability.
|
||||||
3. **Wait for a response** - We will acknowledge your email within 48 hours and provide an estimated timeline for a fix.
|
3. **Wait for a response** - We will acknowledge your email within 48 hours and provide an estimated timeline for a fix.
|
||||||
4. **Work with us** - We may ask for additional information to help us understand and address the issue.
|
4. **Work with us** - We may ask for additional information to help us understand and address the issue.
|
||||||
|
|
||||||
## What to Include in a Report
|
## What to Include in a Report
|
||||||
|
|
||||||
When reporting a vulnerability, please include:
|
When reporting a vulnerability, please include:
|
||||||
|
|
||||||
- A clear description of the issue
|
- A clear description of the issue
|
||||||
- Steps to reproduce the vulnerability
|
- Steps to reproduce the vulnerability
|
||||||
- The potential impact of the vulnerability
|
- The potential impact of the vulnerability
|
||||||
- Any possible mitigations you've identified
|
- Any possible mitigations you've identified
|
||||||
|
|
||||||
## What to Expect
|
## What to Expect
|
||||||
|
|
||||||
- We will acknowledge receipt of your vulnerability report within 48 hours.
|
- We will acknowledge receipt of your vulnerability report within 48 hours.
|
||||||
- We will provide regular updates about our progress.
|
- We will provide regular updates about our progress.
|
||||||
- We will notify you when the vulnerability is fixed.
|
- We will notify you when the vulnerability is fixed.
|
||||||
- With your permission, we will include your name in the acknowledgments.
|
- With your permission, we will include your name in the acknowledgments.
|
||||||
|
|
||||||
## Security Measures
|
## Security Measures
|
||||||
|
|
||||||
This project follows these security practices:
|
This project follows these security practices:
|
||||||
|
|
||||||
- Regular dependency updates via Dependabot
|
- Regular dependency updates via Dependabot
|
||||||
- CodeQL security scanning
|
- CodeQL security scanning
|
||||||
- Automated testing for each pull request
|
- Automated testing for each pull request
|
||||||
- Code review requirements for all changes
|
- Code review requirements for all changes
|
||||||
|
|||||||
+49
-49
@@ -1,86 +1,86 @@
|
|||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
# Check for updates to GitHub Actions
|
# Check for updates to GitHub Actions
|
||||||
- package-ecosystem: 'github-actions'
|
- package-ecosystem: "github-actions"
|
||||||
directory: '/'
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: 'weekly'
|
interval: "weekly"
|
||||||
day: 'monday'
|
day: "monday"
|
||||||
time: '07:00'
|
time: "07:00"
|
||||||
timezone: 'Europe/Amsterdam'
|
timezone: "Europe/Amsterdam"
|
||||||
open-pull-requests-limit: 2
|
open-pull-requests-limit: 2
|
||||||
labels:
|
labels:
|
||||||
- 'dependencies'
|
- "dependencies"
|
||||||
- 'dependencies/github-actions'
|
- "dependencies/github-actions"
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: 'ci'
|
prefix: "ci"
|
||||||
include: 'scope'
|
include: "scope"
|
||||||
|
|
||||||
# Check for updates to Docker
|
# Check for updates to Docker
|
||||||
- package-ecosystem: 'docker'
|
- package-ecosystem: "docker"
|
||||||
directory: '/'
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: 'weekly'
|
interval: "weekly"
|
||||||
day: 'monday'
|
day: "monday"
|
||||||
time: '07:00'
|
time: "07:00"
|
||||||
timezone: 'Europe/Amsterdam'
|
timezone: "Europe/Amsterdam"
|
||||||
open-pull-requests-limit: 2
|
open-pull-requests-limit: 2
|
||||||
labels:
|
labels:
|
||||||
- 'dependencies'
|
- "dependencies"
|
||||||
- 'dependencies/docker'
|
- "dependencies/docker"
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: 'docker'
|
prefix: "docker"
|
||||||
include: 'scope'
|
include: "scope"
|
||||||
groups:
|
groups:
|
||||||
docker:
|
docker:
|
||||||
patterns:
|
patterns:
|
||||||
- '*'
|
- "*"
|
||||||
update-types:
|
update-types:
|
||||||
- 'minor'
|
- "minor"
|
||||||
- 'patch'
|
- "patch"
|
||||||
|
|
||||||
# Check for updates to Docker Compose
|
# Check for updates to Docker Compose
|
||||||
- package-ecosystem: 'docker-compose'
|
- package-ecosystem: "docker-compose"
|
||||||
directory: '/'
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: 'weekly'
|
interval: "weekly"
|
||||||
day: 'monday'
|
day: "monday"
|
||||||
time: '07:00'
|
time: "07:00"
|
||||||
timezone: 'Europe/Amsterdam'
|
timezone: "Europe/Amsterdam"
|
||||||
open-pull-requests-limit: 2
|
open-pull-requests-limit: 2
|
||||||
labels:
|
labels:
|
||||||
- 'dependencies'
|
- "dependencies"
|
||||||
- 'dependencies/docker-compose'
|
- "dependencies/docker-compose"
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: 'docker'
|
prefix: "docker"
|
||||||
include: 'scope'
|
include: "scope"
|
||||||
groups:
|
groups:
|
||||||
docker:
|
docker:
|
||||||
patterns:
|
patterns:
|
||||||
- '*'
|
- "*"
|
||||||
update-types:
|
update-types:
|
||||||
- 'minor'
|
- "minor"
|
||||||
- 'patch'
|
- "patch"
|
||||||
|
|
||||||
# Check for updates to Go modules
|
# Check for updates to Go modules
|
||||||
- package-ecosystem: 'gomod'
|
- package-ecosystem: "gomod"
|
||||||
directory: '/'
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: 'weekly'
|
interval: "weekly"
|
||||||
day: 'monday'
|
day: "monday"
|
||||||
time: '07:00'
|
time: "07:00"
|
||||||
timezone: 'Europe/Amsterdam'
|
timezone: "Europe/Amsterdam"
|
||||||
open-pull-requests-limit: 2
|
open-pull-requests-limit: 2
|
||||||
labels:
|
labels:
|
||||||
- 'dependencies'
|
- "dependencies"
|
||||||
- 'dependencies/go'
|
- "dependencies/go"
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: 'deps'
|
prefix: "deps"
|
||||||
include: 'scope'
|
include: "scope"
|
||||||
groups:
|
groups:
|
||||||
go-modules:
|
go-modules:
|
||||||
patterns:
|
patterns:
|
||||||
- '*'
|
- "*"
|
||||||
update-types:
|
update-types:
|
||||||
- 'minor'
|
- "minor"
|
||||||
- 'patch'
|
- "patch"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: autofix.ci
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [ "master" ]
|
branches: ["master"]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -11,13 +11,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install Task
|
- name: Install Task
|
||||||
uses: go-task/setup-task@v1
|
uses: go-task/setup-task@v2
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with: { go-version-file: 'go.mod' }
|
with: { go-version-file: "go.mod" }
|
||||||
|
|
||||||
- name: Setup go deps
|
- name: Setup go deps
|
||||||
run: |
|
run: |
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
run: golangci-lint run --fix
|
run: golangci-lint run --fix
|
||||||
|
|
||||||
- name: Run golangci-lint format
|
- name: Run golangci-lint format
|
||||||
run: golangci-lint format
|
run: golangci-lint fmt
|
||||||
|
|
||||||
- name: Run go mod tidy
|
- name: Run go mod tidy
|
||||||
run: go mod tidy
|
run: go mod tidy
|
||||||
|
|||||||
+157
-143
@@ -2,7 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['master', 'develop']
|
branches: ["master", "develop"]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -21,12 +21,12 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: stable
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v8
|
uses: golangci/golangci-lint-action@v9
|
||||||
with: { version: latest }
|
with: { version: latest }
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@@ -35,26 +35,26 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
go:
|
|
||||||
- 1.21.x
|
|
||||||
- 1.22.x
|
|
||||||
- 1.23.x
|
|
||||||
- 1.24.x
|
|
||||||
- 1.25.x
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Go ${{ matrix.go }}
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
id: setup-go
|
||||||
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go }}
|
go-version-file: go.mod
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
|
# Expose the resolved Go version as an env var so it is never
|
||||||
|
# interpolated directly into a shell script (avoids script injection).
|
||||||
|
- name: Export Go version
|
||||||
|
env:
|
||||||
|
GO_VERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||||
|
run: echo "GO_VERSION=$GO_VERSION" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Install Task
|
- name: Install Task
|
||||||
uses: go-task/setup-task@v1
|
uses: go-task/setup-task@v2
|
||||||
|
|
||||||
- name: Show build info
|
- name: Show build info
|
||||||
run: task info
|
run: task info
|
||||||
@@ -67,14 +67,18 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests with enhanced reporting
|
- name: Run tests with enhanced reporting
|
||||||
id: test
|
id: test
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 1
|
||||||
run: |
|
run: |
|
||||||
cat >> $GITHUB_STEP_SUMMARY << EOF
|
{
|
||||||
|
cat << EOF
|
||||||
## 🔧 Test Environment
|
## 🔧 Test Environment
|
||||||
- **Go Version:** ${{ matrix.go }}
|
- **Go Version:** $GO_VERSION
|
||||||
- **OS:** ubuntu-latest
|
- **OS:** ubuntu-latest
|
||||||
- **Timestamp:** $(date -u)
|
- **Timestamp:** $(date -u)
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
echo "Running tests with coverage..."
|
echo "Running tests with coverage..."
|
||||||
task test:coverage 2>&1 | tee test-output.log
|
task test:coverage 2>&1 | tee test-output.log
|
||||||
@@ -87,16 +91,17 @@ jobs:
|
|||||||
SKIPPED_TESTS=$(grep -c "--- SKIP:" test-output.log || echo "0")
|
SKIPPED_TESTS=$(grep -c "--- SKIP:" test-output.log || echo "0")
|
||||||
|
|
||||||
# Generate test summary
|
# Generate test summary
|
||||||
cat >> $GITHUB_STEP_SUMMARY << EOF
|
{
|
||||||
## 🧪 Test Results (Go ${{ matrix.go }})
|
cat << EOF
|
||||||
|
## 🧪 Test Results (Go $GO_VERSION)
|
||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
| ----------- | ----------------------------------------------------------- |
|
| ----------- | ------------------------------------------------------------- |
|
||||||
| Total Tests | $TOTAL_TESTS |
|
| Total Tests | $TOTAL_TESTS |
|
||||||
| Passed | $PASSED_TESTS |
|
| Passed | $PASSED_TESTS |
|
||||||
| Failed | $FAILED_TESTS |
|
| Failed | $FAILED_TESTS |
|
||||||
| Skipped | $SKIPPED_TESTS |
|
| Skipped | $SKIPPED_TESTS |
|
||||||
| Status | $([ $TEST_STATUS -eq 0 ] && echo "PASSED" || echo "FAILED") |
|
| Status | $([ "$TEST_STATUS" -eq 0 ] && echo "PASSED" || echo "FAILED") |
|
||||||
|
|
||||||
### 📦 Package Test Results
|
### 📦 Package Test Results
|
||||||
|
|
||||||
@@ -104,42 +109,43 @@ jobs:
|
|||||||
|---------|--------|
|
|---------|--------|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Extract package results
|
# Extract package results
|
||||||
grep "^ok\|^FAIL" test-output.log | while read -r line; do
|
grep "^ok\|^FAIL" test-output.log | while read -r line; do
|
||||||
if [[ $line == ok* ]]; then
|
if [[ $line == ok* ]]; then
|
||||||
pkg=$(echo "$line" | awk '{print $2}')
|
pkg=$(echo "$line" | awk '{print $2}')
|
||||||
echo "| $pkg | ✅ PASS |" >> $GITHUB_STEP_SUMMARY
|
echo "| $pkg | ✅ PASS |"
|
||||||
elif [[ $line == FAIL* ]]; then
|
elif [[ $line == FAIL* ]]; then
|
||||||
pkg=$(echo "$line" | awk '{print $2}')
|
pkg=$(echo "$line" | awk '{print $2}')
|
||||||
echo "| $pkg | ❌ FAIL |" >> $GITHUB_STEP_SUMMARY
|
echo "| $pkg | ❌ FAIL |"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo ""
|
||||||
|
|
||||||
# Add detailed results if tests failed
|
# Add detailed results if tests failed
|
||||||
if [ $TEST_STATUS -ne 0 ]; then
|
if [ "$TEST_STATUS" -ne 0 ]; then
|
||||||
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
|
cat << 'EOF'
|
||||||
### ❌ Failed Tests Details
|
### ❌ Failed Tests Details
|
||||||
```
|
```
|
||||||
EOF
|
EOF
|
||||||
grep -A 10 "--- FAIL:" test-output.log | head -100 >> $GITHUB_STEP_SUMMARY
|
grep -A 10 -- "--- FAIL:" test-output.log | head -100
|
||||||
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
|
cat << 'EOF'
|
||||||
```
|
```
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
# Set outputs for other steps
|
# Set outputs for other steps
|
||||||
cat >> $GITHUB_OUTPUT << EOF
|
{
|
||||||
test-status=$TEST_STATUS
|
echo "test-status=$TEST_STATUS"
|
||||||
total-tests=$TOTAL_TESTS
|
echo "total-tests=$TOTAL_TESTS"
|
||||||
passed-tests=$PASSED_TESTS
|
echo "passed-tests=$PASSED_TESTS"
|
||||||
failed-tests=$FAILED_TESTS
|
echo "failed-tests=$FAILED_TESTS"
|
||||||
EOF
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# Exit with the original test status
|
# Exit with the original test status
|
||||||
exit $TEST_STATUS
|
exit "$TEST_STATUS"
|
||||||
|
|
||||||
- name: Generate coverage report
|
- name: Generate coverage report
|
||||||
if: always()
|
if: always()
|
||||||
@@ -147,8 +153,9 @@ jobs:
|
|||||||
if [ -f coverage/coverage.out ]; then
|
if [ -f coverage/coverage.out ]; then
|
||||||
COVERAGE=$(go tool cover -func=coverage/coverage.out | grep total | awk '{print $3}')
|
COVERAGE=$(go tool cover -func=coverage/coverage.out | grep total | awk '{print $3}')
|
||||||
|
|
||||||
cat >> $GITHUB_STEP_SUMMARY << EOF
|
{
|
||||||
## 📊 Code Coverage (Go ${{ matrix.go }})
|
cat << EOF
|
||||||
|
## 📊 Code Coverage (Go $GO_VERSION)
|
||||||
|
|
||||||
**Total Coverage: $COVERAGE**
|
**Total Coverage: $COVERAGE**
|
||||||
|
|
||||||
@@ -159,45 +166,46 @@ jobs:
|
|||||||
| ------- | -------- |
|
| ------- | -------- |
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Create temporary file for package coverage aggregation
|
# Create temporary file for package coverage aggregation
|
||||||
temp_coverage=$(mktemp)
|
temp_coverage=$(mktemp)
|
||||||
|
|
||||||
# Extract package-level coverage data
|
# Extract package-level coverage data
|
||||||
go tool cover -func=coverage/coverage.out | grep -v total | while read -r line; do
|
go tool cover -func=coverage/coverage.out | grep -v total | while read -r line; do
|
||||||
if [[ $line == *".go:"* ]]; then
|
if [[ $line == *".go:"* ]]; then
|
||||||
# Extract package path from file path (everything before the filename)
|
# Extract package path from file path (everything before the filename)
|
||||||
filepath=$(echo "$line" | awk '{print $1}')
|
filepath=$(echo "$line" | awk '{print $1}')
|
||||||
pkg_path=$(dirname "$filepath" | sed 's|github.com/kjanat/articulate-parser/||; s|^\./||')
|
pkg_path=$(dirname "$filepath" | sed 's|github.com/kjanat/articulate-parser/||; s|^\./||')
|
||||||
coverage=$(echo "$line" | awk '{print $3}' | sed 's/%//')
|
coverage=$(echo "$line" | awk '{print $3}' | sed 's/%//')
|
||||||
|
|
||||||
# Use root package if no subdirectory
|
# Use root package if no subdirectory
|
||||||
[[ "$pkg_path" == "." || "$pkg_path" == "" ]] && pkg_path="root"
|
[[ "$pkg_path" == "." || "$pkg_path" == "" ]] && pkg_path="root"
|
||||||
|
|
||||||
echo "$pkg_path $coverage" >> "$temp_coverage"
|
echo "$pkg_path $coverage" >> "$temp_coverage"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Aggregate coverage by package (average)
|
# Aggregate coverage by package (average)
|
||||||
awk '{
|
awk '{
|
||||||
packages[$1] += $2
|
packages[$1] += $2
|
||||||
counts[$1]++
|
counts[$1]++
|
||||||
}
|
|
||||||
END {
|
|
||||||
for (pkg in packages) {
|
|
||||||
avg = packages[pkg] / counts[pkg]
|
|
||||||
printf "| %s | %.1f%% |\n", pkg, avg
|
|
||||||
}
|
}
|
||||||
}' "$temp_coverage" | sort >> $GITHUB_STEP_SUMMARY
|
END {
|
||||||
|
for (pkg in packages) {
|
||||||
|
avg = packages[pkg] / counts[pkg]
|
||||||
|
printf "| %s | %.1f%% |\n", pkg, avg
|
||||||
|
}
|
||||||
|
}' "$temp_coverage" | sort
|
||||||
|
|
||||||
rm -f "$temp_coverage"
|
rm -f "$temp_coverage"
|
||||||
|
|
||||||
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
|
cat << 'EOF'
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
else
|
else
|
||||||
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
|
cat >> "$GITHUB_STEP_SUMMARY" << 'EOF'
|
||||||
## ⚠️ Coverage Report
|
## ⚠️ Coverage Report
|
||||||
No coverage file generated
|
No coverage file generated
|
||||||
|
|
||||||
@@ -206,9 +214,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload test artifacts
|
- name: Upload test artifacts
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: test-results-go-${{ matrix.go }}
|
name: test-results-go-${{ env.GO_VERSION }}
|
||||||
path: |
|
path: |
|
||||||
test-output.log
|
test-output.log
|
||||||
coverage/
|
coverage/
|
||||||
@@ -216,54 +224,56 @@ jobs:
|
|||||||
|
|
||||||
- name: Run linters
|
- name: Run linters
|
||||||
run: |
|
run: |
|
||||||
# Initialize summary
|
{
|
||||||
cat >> $GITHUB_STEP_SUMMARY << EOF
|
cat << EOF
|
||||||
## 🔍 Static Analysis (Go ${{ matrix.go }})
|
## 🔍 Static Analysis (Go $GO_VERSION)
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Run go vet
|
# Run go vet
|
||||||
VET_OUTPUT=$(task lint:vet 2>&1 || echo "")
|
VET_OUTPUT=$(task lint:vet 2>&1 || echo "")
|
||||||
VET_STATUS=$?
|
VET_STATUS=$?
|
||||||
|
|
||||||
if [ $VET_STATUS -eq 0 ]; then
|
if [ "$VET_STATUS" -eq 0 ]; then
|
||||||
echo "✅ **go vet:** No issues found" >> $GITHUB_STEP_SUMMARY
|
echo "✅ **go vet:** No issues found"
|
||||||
else
|
else
|
||||||
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
|
cat << 'EOF'
|
||||||
❌ **go vet:** Issues found
|
❌ **go vet:** Issues found
|
||||||
|
|
||||||
```
|
```
|
||||||
EOF
|
EOF
|
||||||
echo "$VET_OUTPUT" >> $GITHUB_STEP_SUMMARY
|
echo "$VET_OUTPUT"
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```'
|
||||||
fi
|
fi
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo ""
|
||||||
|
|
||||||
# Run go fmt check
|
# Run go fmt check
|
||||||
FMT_OUTPUT=$(task lint:fmt 2>&1 || echo "")
|
FMT_OUTPUT=$(task lint:fmt 2>&1 || echo "")
|
||||||
FMT_STATUS=$?
|
FMT_STATUS=$?
|
||||||
|
|
||||||
if [ $FMT_STATUS -eq 0 ]; then
|
if [ "$FMT_STATUS" -eq 0 ]; then
|
||||||
echo "✅ **go fmt:** All files properly formatted" >> $GITHUB_STEP_SUMMARY
|
echo "✅ **go fmt:** All files properly formatted"
|
||||||
else
|
else
|
||||||
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
|
cat << 'EOF'
|
||||||
❌ **go fmt:** Files need formatting
|
❌ **go fmt:** Files need formatting
|
||||||
|
|
||||||
```
|
```
|
||||||
EOF
|
EOF
|
||||||
echo "$FMT_OUTPUT" >> $GITHUB_STEP_SUMMARY
|
echo "$FMT_OUTPUT"
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```'
|
||||||
fi
|
fi
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
# Exit with error if any linter failed
|
# Exit with error if any linter failed
|
||||||
[ $VET_STATUS -eq 0 ] && [ $FMT_STATUS -eq 0 ] || exit 1
|
[ "$VET_STATUS" -eq 0 ] && [ "$FMT_STATUS" -eq 0 ] || exit 1
|
||||||
|
|
||||||
- name: Job Summary
|
- name: Job Summary
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
|
{
|
||||||
## 📋 Job Summary (Go ${{ matrix.go }})
|
echo "## 📋 Job Summary (Go $GO_VERSION)"
|
||||||
|
echo ""
|
||||||
|
cat << 'EOF'
|
||||||
| Step | Status |
|
| Step | Status |
|
||||||
| --------------- | --------------------------------------------------------------- |
|
| --------------- | --------------------------------------------------------------- |
|
||||||
| Dependencies | Success |
|
| Dependencies | Success |
|
||||||
@@ -273,12 +283,13 @@ jobs:
|
|||||||
| Static Analysis | ${{ job.status == 'success' && 'Clean' || 'Issues' }} |
|
| Static Analysis | ${{ job.status == 'success' && 'Clean' || 'Issues' }} |
|
||||||
| Code Formatting | ${{ job.status == 'success' && 'Clean' || 'Issues' }} |
|
| Code Formatting | ${{ job.status == 'success' && 'Clean' || 'Issues' }} |
|
||||||
EOF
|
EOF
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v7
|
||||||
with:
|
with:
|
||||||
files: ./coverage/coverage.out
|
files: ./coverage/coverage.out
|
||||||
flags: Go ${{ matrix.go }}
|
flags: Go ${{ env.GO_VERSION }}
|
||||||
slug: kjanat/articulate-parser
|
slug: kjanat/articulate-parser
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
@@ -286,7 +297,7 @@ jobs:
|
|||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
uses: codecov/test-results-action@v1
|
uses: codecov/test-results-action@v1
|
||||||
with:
|
with:
|
||||||
flags: Go ${{ matrix.go }}
|
flags: Go ${{ env.GO_VERSION }}
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
docker-test:
|
docker-test:
|
||||||
@@ -297,56 +308,59 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Install Task
|
- name: Install Task
|
||||||
uses: go-task/setup-task@v1
|
uses: go-task/setup-task@v2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Build Docker image using Task
|
- name: Build Docker image using Task
|
||||||
run: task docker:build
|
run: task docker:build
|
||||||
|
|
||||||
- name: Test Docker image using Task
|
- name: Test Docker image using Task
|
||||||
run: |
|
run: |
|
||||||
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
|
{
|
||||||
|
cat << 'EOF'
|
||||||
## 🧪 Docker Image Tests
|
## 🧪 Docker Image Tests
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Run Task docker test
|
# Run Task docker test
|
||||||
task docker:test
|
task docker:test
|
||||||
|
|
||||||
echo "**Testing help command:**" >> $GITHUB_STEP_SUMMARY
|
echo "**Testing help command:**"
|
||||||
echo '```terminaloutput' >> $GITHUB_STEP_SUMMARY
|
echo '```terminaloutput'
|
||||||
docker run --rm articulate-parser:latest --help >> $GITHUB_STEP_SUMMARY
|
docker run --rm articulate-parser:latest --help
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```'
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo ""
|
||||||
|
|
||||||
# Test image size
|
# Test image size
|
||||||
IMAGE_SIZE=$(docker image inspect articulate-parser:latest --format='{{.Size}}' | numfmt --to=iec-i --suffix=B)
|
IMAGE_SIZE=$(docker image inspect articulate-parser:latest --format='{{.Size}}' | numfmt --to=iec-i --suffix=B)
|
||||||
echo "**Image size:** $IMAGE_SIZE" >> $GITHUB_STEP_SUMMARY
|
echo "**Image size:** $IMAGE_SIZE"
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo ""
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
dependency-review:
|
dependency-review:
|
||||||
name: Dependency Review
|
name: Dependency Review
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- name: 'Checkout Repository'
|
- name: "Checkout Repository"
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: 'Dependency Review'
|
- name: "Dependency Review"
|
||||||
uses: actions/dependency-review-action@v4
|
uses: actions/dependency-review-action@v5
|
||||||
with:
|
with:
|
||||||
fail-on-severity: moderate
|
fail-on-severity: moderate
|
||||||
comment-summary-in-pr: always
|
comment-summary-in-pr: always
|
||||||
@@ -357,37 +371,37 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
needs: [test, docker-test, dependency-review]
|
needs: [test, docker-test]
|
||||||
if: |
|
if: |
|
||||||
github.event_name == 'push' && (github.ref == 'refs/heads/master' ||
|
github.event_name == 'push' && (github.ref == 'refs/heads/master' ||
|
||||||
github.ref == 'refs/heads/develop' ||
|
github.ref == 'refs/heads/develop' ||
|
||||||
startsWith(github.ref, 'refs/heads/feature/docker'))
|
startsWith(github.ref, 'refs/heads/feature/docker'))
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Extract metadata
|
- name: Extract metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.IMAGE_NAME }}
|
${{ env.IMAGE_NAME }}
|
||||||
@@ -408,7 +422,7 @@ jobs:
|
|||||||
org.opencontainers.image.documentation=https://github.com/${{ github.repository }}/blob/master/DOCKER.md
|
org.opencontainers.image.documentation=https://github.com/${{ github.repository }}/blob/master/DOCKER.md
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
# Multi-architecture build - Docker automatically provides TARGETOS, TARGETARCH, etc.
|
# Multi-architecture build - Docker automatically provides TARGETOS, TARGETARCH, etc.
|
||||||
@@ -436,7 +450,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate Docker summary
|
- name: Generate Docker summary
|
||||||
run: |
|
run: |
|
||||||
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
|
cat >> "$GITHUB_STEP_SUMMARY" << 'EOF'
|
||||||
## 🐳 Docker Build Summary
|
## 🐳 Docker Build Summary
|
||||||
|
|
||||||
**Image:** `ghcr.io/${{ github.repository }}`
|
**Image:** `ghcr.io/${{ github.repository }}`
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ name: "CodeQL"
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '44 16 * * 6'
|
- cron: "44 16 * * 6"
|
||||||
# push:
|
# push:
|
||||||
# branches: [ "master" ]
|
# branches: [ "master" ]
|
||||||
# pull_request:
|
# pull_request:
|
||||||
@@ -47,58 +47,58 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- language: actions
|
- language: actions
|
||||||
build-mode: none
|
build-mode: none
|
||||||
- language: go
|
- language: go
|
||||||
build-mode: autobuild
|
build-mode: autobuild
|
||||||
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
# 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 `c-cpp` to analyze code written in C, C++ or both
|
||||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin 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
|
# 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,
|
# 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.
|
# 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
|
# 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
|
# 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:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
# Add any setup steps before running the `github/codeql-action/init` action.
|
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||||
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||||
# or others). This is typically only required for manual builds.
|
# or others). This is typically only required for manual builds.
|
||||||
# - name: Setup runtime (example)
|
# - name: Setup runtime (example)
|
||||||
# uses: actions/setup-example@v1
|
# uses: actions/setup-example@v1
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v4
|
uses: github/codeql-action/init@v4
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: ${{ matrix.build-mode }}
|
build-mode: ${{ matrix.build-mode }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# 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.
|
# 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.
|
# 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
|
# 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
|
# queries: security-extended,security-and-quality
|
||||||
|
|
||||||
# If the analyze step fails for one of the languages you are analyzing with
|
# 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
|
# "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 set the build mode to "manual" for that language. Then modify this step
|
||||||
# to build your code.
|
# to build your code.
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ 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
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
- if: matrix.build-mode == 'manual'
|
- if: matrix.build-mode == 'manual'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
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' \
|
'languages you are analyzing, replace this with the commands to build' \
|
||||||
'your code, for example:'
|
'your code, for example:'
|
||||||
echo ' make bootstrap'
|
echo ' make bootstrap'
|
||||||
echo ' make release'
|
echo ' make release'
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v4
|
uses: github/codeql-action/analyze@v4
|
||||||
with:
|
with:
|
||||||
category: "/language:${{matrix.language}}"
|
category: "/language:${{matrix.language}}"
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ jobs:
|
|||||||
dependency-review:
|
dependency-review:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: 'Checkout Repository'
|
- name: "Checkout Repository"
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: 'Dependency Review'
|
- name: "Dependency Review"
|
||||||
uses: actions/dependency-review-action@v4
|
uses: actions/dependency-review-action@v5
|
||||||
with:
|
with:
|
||||||
fail-on-severity: moderate
|
fail-on-severity: moderate
|
||||||
comment-summary-in-pr: always
|
comment-summary-in-pr: always
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
build/articulate-parser-linux-amd64
|
build/articulate-parser-linux-amd64
|
||||||
@@ -82,36 +82,36 @@ jobs:
|
|||||||
docker:
|
docker:
|
||||||
name: Docker Build & Push
|
name: Docker Build & Push
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: ['release']
|
needs: ["release"]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Extract metadata
|
- name: Extract metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.IMAGE_NAME }}
|
${{ env.IMAGE_NAME }}
|
||||||
@@ -131,7 +131,7 @@ jobs:
|
|||||||
org.opencontainers.image.documentation=https://github.com/${{ github.repository }}/blob/master/DOCKER.md
|
org.opencontainers.image.documentation=https://github.com/${{ github.repository }}/blob/master/DOCKER.md
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: |
|
platforms: |
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ output/
|
|||||||
outputs/
|
outputs/
|
||||||
articulate-sample.json
|
articulate-sample.json
|
||||||
test-output.*
|
test-output.*
|
||||||
|
# Example test exports (defensive; tests now write to a temp dir)
|
||||||
|
internal/exporters/output.*
|
||||||
go-os-arch-matrix.csv
|
go-os-arch-matrix.csv
|
||||||
test_godocx.go
|
test_godocx.go
|
||||||
test_input.json
|
test_input.json
|
||||||
|
|||||||
+59
-53
@@ -1,6 +1,7 @@
|
|||||||
|
# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json
|
||||||
# golangci-lint configuration for articulate-parser
|
# golangci-lint configuration for articulate-parser
|
||||||
# https://golangci-lint.run/usage/configuration/
|
# https://golangci-lint.run/usage/configuration/
|
||||||
|
---
|
||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
# Options for analysis running
|
# Options for analysis running
|
||||||
@@ -8,11 +9,6 @@ run:
|
|||||||
# Timeout for total work
|
# Timeout for total work
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
|
|
||||||
# Skip directories (not allowed in config v2, will use issues exclude instead)
|
|
||||||
|
|
||||||
# Go version
|
|
||||||
go: "1.24"
|
|
||||||
|
|
||||||
# Include test files
|
# Include test files
|
||||||
tests: true
|
tests: true
|
||||||
|
|
||||||
@@ -56,6 +52,7 @@ formatters:
|
|||||||
- gofmt
|
- gofmt
|
||||||
- goimports
|
- goimports
|
||||||
- gofumpt
|
- gofumpt
|
||||||
|
exclusions: { paths: [".zed"] }
|
||||||
settings:
|
settings:
|
||||||
# gofmt settings
|
# gofmt settings
|
||||||
gofmt:
|
gofmt:
|
||||||
@@ -79,68 +76,68 @@ linters:
|
|||||||
# Enable specific linters
|
# Enable specific linters
|
||||||
enable:
|
enable:
|
||||||
# Default/standard linters
|
# Default/standard linters
|
||||||
- errcheck # Check for unchecked errors
|
- errcheck # Check for unchecked errors
|
||||||
- govet # Go vet
|
- govet # Go vet
|
||||||
- ineffassign # Detect ineffectual assignments
|
- ineffassign # Detect ineffectual assignments
|
||||||
- staticcheck # Staticcheck
|
- staticcheck # Staticcheck
|
||||||
- unused # Find unused code
|
- unused # Find unused code
|
||||||
|
|
||||||
# Code quality
|
# Code quality
|
||||||
- revive # Fast, configurable linter
|
- revive # Fast, configurable linter
|
||||||
- gocritic # Opinionated Go source code linter
|
- gocritic # Opinionated Go source code linter
|
||||||
- godot # Check comment periods
|
- godot # Check comment periods
|
||||||
- godox # Detect TODO/FIXME comments
|
- godox # Detect TODO/FIXME comments
|
||||||
- gocognit # Cognitive complexity
|
- gocognit # Cognitive complexity
|
||||||
- gocyclo # Cyclomatic complexity
|
- gocyclo # Cyclomatic complexity
|
||||||
- funlen # Function length
|
- funlen # Function length
|
||||||
- maintidx # Maintainability index
|
- maintidx # Maintainability index
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
- gosec # Security problems
|
- gosec # Security problems
|
||||||
|
|
||||||
# Performance
|
# Performance
|
||||||
- prealloc # Find slice preallocation opportunities
|
- prealloc # Find slice preallocation opportunities
|
||||||
- bodyclose # Check HTTP response body closed
|
- bodyclose # Check HTTP response body closed
|
||||||
|
|
||||||
# Style and formatting
|
# Style and formatting
|
||||||
- goconst # Find repeated strings
|
- goconst # Find repeated strings
|
||||||
- misspell # Find misspellings
|
- misspell # Find misspellings
|
||||||
- whitespace # Find unnecessary blank lines
|
- whitespace # Find unnecessary blank lines
|
||||||
- unconvert # Remove unnecessary type conversions
|
- unconvert # Remove unnecessary type conversions
|
||||||
- dupword # Check for duplicate words
|
- dupword # Check for duplicate words
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
- errorlint # Error handling improvements
|
- errorlint # Error handling improvements
|
||||||
- wrapcheck # Check error wrapping
|
- wrapcheck # Check error wrapping
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
- testifylint # Testify usage
|
- testifylint # Testify usage
|
||||||
- tparallel # Detect improper t.Parallel() usage
|
- tparallel # Detect improper t.Parallel() usage
|
||||||
- thelper # Detect test helpers without t.Helper()
|
- thelper # Detect test helpers without t.Helper()
|
||||||
|
|
||||||
# Best practices
|
# Best practices
|
||||||
- exhaustive # Check exhaustiveness of enum switches
|
- exhaustive # Check exhaustiveness of enum switches
|
||||||
- nolintlint # Check nolint directives
|
- nolintlint # Check nolint directives
|
||||||
- nakedret # Find naked returns
|
- nakedret # Find naked returns
|
||||||
- nilnil # Check for redundant nil checks
|
- nilnil # Check for redundant nil checks
|
||||||
- noctx # Check sending HTTP requests without context
|
- noctx # Check sending HTTP requests without context
|
||||||
- contextcheck # Check context propagation
|
- contextcheck # Check context propagation
|
||||||
- asciicheck # Check for non-ASCII identifiers
|
- asciicheck # Check for non-ASCII identifiers
|
||||||
- bidichk # Check for dangerous unicode sequences
|
- bidichk # Check for dangerous unicode sequences
|
||||||
- durationcheck # Check for multiplied durations
|
- durationcheck # Check for multiplied durations
|
||||||
- makezero # Find slice declarations with non-zero length
|
- makezero # Find slice declarations with non-zero length
|
||||||
- nilerr # Find code returning nil with non-nil error
|
- nilerr # Find code returning nil with non-nil error
|
||||||
- predeclared # Find code shadowing predeclared identifiers
|
- predeclared # Find code shadowing predeclared identifiers
|
||||||
- promlinter # Check Prometheus metrics naming
|
- promlinter # Check Prometheus metrics naming
|
||||||
- reassign # Check reassignment of package variables
|
- reassign # Check reassignment of package variables
|
||||||
- usestdlibvars # Use variables from stdlib
|
- usestdlibvars # Use variables from stdlib
|
||||||
- wastedassign # Find wasted assignments
|
- wastedassign # Find wasted assignments
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
- godoclint # Check godoc comments
|
- godoclint # Check godoc comments
|
||||||
|
|
||||||
# New
|
# New
|
||||||
- modernize # Suggest simplifications using new Go features
|
- modernize # Suggest simplifications using new Go features
|
||||||
|
|
||||||
# Exclusion rules for linters
|
# Exclusion rules for linters
|
||||||
exclusions:
|
exclusions:
|
||||||
@@ -221,8 +218,8 @@ linters:
|
|||||||
govet:
|
govet:
|
||||||
enable-all: true
|
enable-all: true
|
||||||
disable:
|
disable:
|
||||||
- fieldalignment # Too many false positives
|
- fieldalignment # Too many false positives
|
||||||
- shadow # Can be noisy
|
- shadow # Can be noisy
|
||||||
|
|
||||||
# goconst settings
|
# goconst settings
|
||||||
goconst:
|
goconst:
|
||||||
@@ -286,8 +283,8 @@ linters:
|
|||||||
severity: medium
|
severity: medium
|
||||||
confidence: medium
|
confidence: medium
|
||||||
excludes:
|
excludes:
|
||||||
- G104 # Handled by errcheck
|
- G104 # Handled by errcheck
|
||||||
- G304 # File path provided as taint input
|
- G304 # File path provided as taint input
|
||||||
|
|
||||||
# revive settings
|
# revive settings
|
||||||
revive:
|
revive:
|
||||||
@@ -349,7 +346,16 @@ linters:
|
|||||||
|
|
||||||
# stylecheck settings
|
# stylecheck settings
|
||||||
staticcheck:
|
staticcheck:
|
||||||
checks: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"]
|
checks:
|
||||||
|
[
|
||||||
|
"all",
|
||||||
|
"-ST1000",
|
||||||
|
"-ST1003",
|
||||||
|
"-ST1016",
|
||||||
|
"-ST1020",
|
||||||
|
"-ST1021",
|
||||||
|
"-ST1022",
|
||||||
|
]
|
||||||
|
|
||||||
# maintidx settings
|
# maintidx settings
|
||||||
maintidx:
|
maintidx:
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"lsp": {
|
||||||
|
"golangci-lint": {
|
||||||
|
"command": ["golangci-lint-langserver"],
|
||||||
|
"extensions": [".go"],
|
||||||
|
"initialization": {
|
||||||
|
"command": [
|
||||||
|
"golangci-lint",
|
||||||
|
"run",
|
||||||
|
"--output.json.path",
|
||||||
|
"stdout",
|
||||||
|
"--show-stats=false",
|
||||||
|
"--issues-exit-code=1",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"dprint": {
|
||||||
|
"command": ["dprint", "fmt", "$FILE"],
|
||||||
|
"extensions": [".go", ".md", ".Dockerfile", ".Dockerfile.dev", ".md"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# See https://pre-commit.com for more information
|
||||||
|
# See https://pre-commit.com/hooks.html for more hooks
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v6.0.0
|
||||||
|
hooks:
|
||||||
|
# File quality
|
||||||
|
- id: trailing-whitespace
|
||||||
|
exclude: '^\.github/ISSUE_TEMPLATE/.*\.yml$'
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: mixed-line-ending
|
||||||
|
args: ["--fix=lf"]
|
||||||
|
|
||||||
|
# File validation
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-json
|
||||||
|
- id: check-toml
|
||||||
|
|
||||||
|
# Security
|
||||||
|
- id: detect-private-key
|
||||||
|
|
||||||
|
# Git safety
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: check-case-conflict
|
||||||
|
- id: no-commit-to-branch
|
||||||
|
args: ["--branch=master", "--branch=main"]
|
||||||
|
|
||||||
|
# File structure
|
||||||
|
- id: check-added-large-files
|
||||||
|
- id: check-symlinks
|
||||||
|
- id: check-executables-have-shebangs
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: actionlint
|
||||||
|
name: Lint GitHub Actions workflow files
|
||||||
|
description: Runs actionlint to lint GitHub Actions workflow files
|
||||||
|
language: golang
|
||||||
|
types: ["yaml"]
|
||||||
|
files: ^\.github/workflows/
|
||||||
|
entry: actionlint
|
||||||
|
minimum_pre_commit_version: 3.0.0
|
||||||
|
- repo: https://github.com/golangci/golangci-lint
|
||||||
|
rev: v2.7.2
|
||||||
|
hooks:
|
||||||
|
- id: golangci-lint
|
||||||
|
name: golangci-lint
|
||||||
|
description: Fast linters runner for Go. Note that only modified files are linted, so linters like 'unused' that need to scan all files won't work as expected.
|
||||||
|
entry: golangci-lint run --new-from-rev HEAD --fix
|
||||||
|
types: [go]
|
||||||
|
language: golang
|
||||||
|
require_serial: true
|
||||||
|
pass_filenames: false
|
||||||
|
# - id: golangci-lint-full
|
||||||
|
# name: golangci-lint-full
|
||||||
|
# description: Fast linters runner for Go. Runs on all files in the module. Use this hook if you use pre-commit in CI.
|
||||||
|
# entry: golangci-lint run --fix
|
||||||
|
# types: [go]
|
||||||
|
# language: golang
|
||||||
|
# require_serial: true
|
||||||
|
# pass_filenames: false
|
||||||
|
- id: golangci-lint-fmt
|
||||||
|
name: golangci-lint-fmt
|
||||||
|
description: Fast linters runner for Go. Formats all files in the repo.
|
||||||
|
entry: golangci-lint fmt
|
||||||
|
types: [go]
|
||||||
|
language: golang
|
||||||
|
require_serial: true
|
||||||
|
pass_filenames: false
|
||||||
|
- id: golangci-lint-config-verify
|
||||||
|
name: golangci-lint-config-verify
|
||||||
|
description: Verifies the configuration file
|
||||||
|
entry: golangci-lint config verify
|
||||||
|
files: '\.golangci\.(?:yml|yaml|toml|json)'
|
||||||
|
language: golang
|
||||||
|
pass_filenames: false
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"tab_size": 2,
|
||||||
|
"hard_tabs": true,
|
||||||
|
"prettier": { "allowed": false },
|
||||||
|
"formatter": [
|
||||||
|
{ "language_server": { "name": "dprint" } }
|
||||||
|
],
|
||||||
|
"languages": {
|
||||||
|
"Go": {
|
||||||
|
"language_servers": ["gopls", "golangci-lint-langserver", "..."]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lsp": {
|
||||||
|
"gopls": {
|
||||||
|
"enable_lsp_tasks": true,
|
||||||
|
"initialization_options": {
|
||||||
|
"workspaceFiles": ["go.mod", "go.work", "*.go"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"golangci-lint": {
|
||||||
|
"enable_lsp_tasks": false
|
||||||
|
},
|
||||||
|
"golangci-lint-langserver": {
|
||||||
|
"binary": {
|
||||||
|
"path": "golangci-lint-langserver",
|
||||||
|
"arguments": ["-debug"]
|
||||||
|
},
|
||||||
|
"initialization_options": {
|
||||||
|
"command": [
|
||||||
|
"golangci-lint",
|
||||||
|
"run",
|
||||||
|
"--output.json.path",
|
||||||
|
"stdout",
|
||||||
|
"--show-stats=false",
|
||||||
|
"--issues-exit-code=1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +1,183 @@
|
|||||||
# Agent Guidelines for articulate-parser
|
# Agent Guidelines for articulate-parser
|
||||||
|
|
||||||
|
A Go CLI tool that parses Articulate Rise courses from URLs or local JSON files and exports them to Markdown, HTML, or DOCX formats.
|
||||||
|
|
||||||
|
## Repository Info
|
||||||
|
|
||||||
|
- **GitHub**: https://github.com/kjanat/articulate-parser
|
||||||
|
- **Default branch**: `master` (not `main`)
|
||||||
|
|
||||||
## Build/Test Commands
|
## Build/Test Commands
|
||||||
- **Build**: `task build` or `go build -o bin/articulate-parser main.go`
|
|
||||||
- **Run tests**: `task test` or `go test -race -timeout 5m ./...`
|
### Primary Commands (using Taskfile)
|
||||||
- **Run single test**: `go test -v -race -run ^TestName$ ./path/to/package`
|
|
||||||
- **Test with coverage**:
|
```bash
|
||||||
- `task test:coverage` or
|
task build # Build binary to bin/articulate-parser
|
||||||
- `go test -race -coverprofile=coverage/coverage.out -covermode=atomic ./...`
|
task test # Run all tests with race detection
|
||||||
- **Lint**: `task lint` (runs vet, fmt check, staticcheck, golangci-lint)
|
task lint # Run all linters (vet, fmt, staticcheck, golangci-lint)
|
||||||
- **Format**: `task fmt` or `gofmt -s -w .`
|
task fmt # Format all Go files
|
||||||
- **CI checks**: `task ci` (deps, lint, test with coverage, build)
|
task ci # Full CI pipeline: deps, lint, test with coverage, build
|
||||||
|
task qa # Quick QA: fmt + lint + test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Go Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
go build -o bin/articulate-parser main.go
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
go test -race -timeout 5m ./...
|
||||||
|
|
||||||
|
# Run single test by name
|
||||||
|
go test -v -race -run ^TestMarkdownExporter_Export$ ./internal/exporters
|
||||||
|
|
||||||
|
# Run tests in specific package
|
||||||
|
go test -v -race ./internal/services
|
||||||
|
|
||||||
|
# Run tests matching pattern
|
||||||
|
go test -v -race -run "TestParser" ./...
|
||||||
|
|
||||||
|
# Test with coverage
|
||||||
|
go test -race -coverprofile=coverage/coverage.out -covermode=atomic ./...
|
||||||
|
go tool cover -html=coverage/coverage.out -o coverage/coverage.html
|
||||||
|
|
||||||
|
# Benchmarks
|
||||||
|
go test -bench=. -benchmem ./...
|
||||||
|
go test -bench=BenchmarkMarkdownExporter ./internal/exporters
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security & Auditing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task security:check # Run gosec security scanner
|
||||||
|
task security:audit # Run govulncheck for vulnerabilities
|
||||||
|
```
|
||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style Guidelines
|
||||||
|
|
||||||
### Imports
|
### Imports
|
||||||
|
|
||||||
- Use `goimports` with local prefix: `github.com/kjanat/articulate-parser`
|
- Use `goimports` with local prefix: `github.com/kjanat/articulate-parser`
|
||||||
- Order: stdlib, external, internal packages
|
- Order: stdlib, blank line, external packages, blank line, internal packages
|
||||||
- Group related imports together
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/fumiama/go-docx"
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
### Formatting
|
### Formatting
|
||||||
|
|
||||||
- Use `gofmt -s` (simplify) and `gofumpt` with extra rules
|
- Use `gofmt -s` (simplify) and `gofumpt` with extra rules
|
||||||
- Function length: max 100 lines, 50 statements
|
- Function length: max 100 lines, 50 statements
|
||||||
- Cyclomatic complexity: max 15
|
- Cyclomatic complexity: max 15; Cognitive complexity: max 20
|
||||||
- Cognitive complexity: max 20
|
|
||||||
|
|
||||||
### Types & Naming
|
### Types & Naming
|
||||||
|
|
||||||
- Use interface-based design (see `internal/interfaces/`)
|
- Use interface-based design (see `internal/interfaces/`)
|
||||||
- Export types/functions with clear godoc comments ending with period
|
- Exported types/functions require godoc comments ending with period
|
||||||
- Use descriptive names: `ArticulateParser`, `MarkdownExporter`
|
- Use descriptive names: `ArticulateParser`, `MarkdownExporter`
|
||||||
- Receiver names: short (1-2 chars), consistent per type
|
- Receiver names: short (1-2 chars), consistent per type
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
- Always wrap errors with context: `fmt.Errorf("operation failed: %w", err)`
|
- Always wrap errors with context: `fmt.Errorf("operation failed: %w", err)`
|
||||||
- Use `%w` verb for error wrapping to preserve error chain
|
- Use `%w` verb for error wrapping to preserve error chain
|
||||||
- Check all error returns (enforced by `errcheck`)
|
- Check all error returns (enforced by `errcheck`)
|
||||||
- Document error handling rationale in defer blocks when ignoring close errors
|
- Document error handling rationale in defer blocks when ignoring close errors
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Good: Error wrapping with context
|
||||||
|
if err := json.Unmarshal(body, &course); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Documented defer with error handling
|
||||||
|
defer func() {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
p.Logger.Warn("failed to close response body", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
### Comments
|
### Comments
|
||||||
|
|
||||||
- All exported types/functions require godoc comments
|
- All exported types/functions require godoc comments
|
||||||
- End sentences with periods (`godot` linter enforced)
|
- End sentences with periods (`godot` linter enforced)
|
||||||
- Mark known issues with TODO/FIXME/HACK/BUG/XXX
|
- Mark known issues with TODO/FIXME/HACK/BUG/XXX
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- Use `#nosec` with justification for deliberate security exceptions (G304 for CLI file paths, G306 for export file permissions)
|
|
||||||
- Run `gosec` and `govulncheck` for security audits
|
- Use `#nosec` with justification for deliberate security exceptions
|
||||||
|
- G304: File paths from CLI args; G306: Export file permissions
|
||||||
|
|
||||||
|
```go
|
||||||
|
// #nosec G304 - File path provided by user via CLI argument
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
- Enable race detection: `-race` flag
|
|
||||||
|
- Enable race detection: `-race` flag always
|
||||||
- Use table-driven tests where applicable
|
- Use table-driven tests where applicable
|
||||||
- Mark test helpers with `t.Helper()`
|
- Mark test helpers with `t.Helper()`
|
||||||
|
- Use `t.TempDir()` for temporary files
|
||||||
- Benchmarks in `*_bench_test.go`, examples in `*_example_test.go`
|
- Benchmarks in `*_bench_test.go`, examples in `*_example_test.go`
|
||||||
|
- Test naming: `Test<Type>_<Method>` or `Test<Function>`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, itemType, expectedText string
|
||||||
|
}{
|
||||||
|
{"text item", "text", ""},
|
||||||
|
{"divider item", "divider", "---"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// test implementation
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
- Minimal external dependencies (currently: go-docx, golang.org/x/net, golang.org/x/text)
|
|
||||||
|
- Minimal external dependencies (go-docx, golang.org/x/net, golang.org/x/text)
|
||||||
- Run `task deps:tidy` after adding/removing dependencies
|
- Run `task deps:tidy` after adding/removing dependencies
|
||||||
|
- CGO disabled by default (`CGO_ENABLED=0`)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
articulate-parser/
|
||||||
|
internal/
|
||||||
|
config/ # Configuration loading
|
||||||
|
exporters/ # Export implementations (markdown, html, docx)
|
||||||
|
interfaces/ # Core interfaces (Exporter, CourseParser, Logger)
|
||||||
|
models/ # Data models (Course, Lesson, Item, Media)
|
||||||
|
services/ # Core services (parser, html cleaner, app, logger)
|
||||||
|
version/ # Version information
|
||||||
|
main.go # Application entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Creating a new exporter
|
||||||
|
|
||||||
|
1. Implement `interfaces.Exporter` interface
|
||||||
|
2. Add factory method to `internal/exporters/factory.go`
|
||||||
|
3. Register format in `NewFactory()`
|
||||||
|
4. Add tests following existing patterns
|
||||||
|
|
||||||
|
### Adding configuration options
|
||||||
|
|
||||||
|
1. Add field to `Config` struct in `internal/config/config.go`
|
||||||
|
2. Load from environment variable with sensible default
|
||||||
|
3. Document in config struct comments
|
||||||
|
|||||||
@@ -49,17 +49,17 @@ docker run --rm ghcr.io/kjanat/articulate-parser:latest --version
|
|||||||
|
|
||||||
## Available Tags
|
## Available Tags
|
||||||
|
|
||||||
- `latest` - Latest stable release
|
- `latest` - Latest stable release
|
||||||
- `v1.x.x` - Specific version tags
|
- `v1.x.x` - Specific version tags
|
||||||
- `main` - Latest development build
|
- `main` - Latest development build
|
||||||
|
|
||||||
## Image Details
|
## Image Details
|
||||||
|
|
||||||
- **Base Image**: `scratch` (minimal attack surface)
|
- **Base Image**: `scratch` (minimal attack surface)
|
||||||
- **Architecture**: Multi-arch support (amd64, arm64)
|
- **Architecture**: Multi-arch support (amd64, arm64)
|
||||||
- **Size**: < 10MB (optimized binary)
|
- **Size**: < 10MB (optimized binary)
|
||||||
- **Security**: Runs as non-root user
|
- **Security**: Runs as non-root user
|
||||||
- **Features**: SBOM and provenance attestation included
|
- **Features**: SBOM and provenance attestation included
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -77,6 +77,6 @@ docker-compose up --build
|
|||||||
|
|
||||||
## Repository
|
## Repository
|
||||||
|
|
||||||
- **Source**: [github.com/kjanat/articulate-parser](https://github.com/kjanat/articulate-parser)
|
- **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)
|
- **Issues**: [Report bugs or request features](https://github.com/kjanat/articulate-parser/issues)
|
||||||
- **License**: See repository for license details
|
- **License**: See repository for license details
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.27-alpine AS builder
|
||||||
|
|
||||||
# Install git and ca-certificates (needed for fetching dependencies and HTTPS)
|
# Install git and ca-certificates (needed for fetching dependencies and HTTPS)
|
||||||
RUN apk add --no-cache git ca-certificates tzdata file
|
RUN apk add --no-cache git ca-certificates tzdata file
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
# Uses Alpine instead of scratch for debugging
|
# Uses Alpine instead of scratch for debugging
|
||||||
|
|
||||||
# Build stage - same as production
|
# Build stage - same as production
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.27-alpine AS builder
|
||||||
|
|
||||||
# Install git and ca-certificates (needed for fetching dependencies and HTTPS)
|
# Install git and ca-certificates (needed for fetching dependencies and HTTPS)
|
||||||
RUN apk add --no-cache git ca-certificates tzdata file
|
RUN apk add --no-cache git ca-certificates tzdata file
|
||||||
|
|||||||
@@ -78,32 +78,32 @@ flowchart TD
|
|||||||
|
|
||||||
The system follows **Clean Architecture** principles with clear separation of concerns:
|
The system follows **Clean Architecture** principles with clear separation of concerns:
|
||||||
|
|
||||||
- **🎯 Entry Point**: Command-line interface handles user input and coordinates operations
|
- **Entry Point**: Command-line interface handles user input and coordinates operations
|
||||||
- **🏗️ Application Layer**: Core business logic with dependency injection
|
- **Application Layer**: Core business logic with dependency injection
|
||||||
- **📋 Interface Layer**: Contracts defining behavior without implementation details
|
- **Interface Layer**: Contracts defining behavior without implementation details
|
||||||
- **🔧 Service Layer**: Concrete implementations of parsing and utility services
|
- **Service Layer**: Concrete implementations of parsing and utility services
|
||||||
- **📤 Export Layer**: Factory pattern for format-specific exporters
|
- **Export Layer**: Factory pattern for format-specific exporters
|
||||||
- **📊 Data Layer**: Domain models representing course structure
|
- **Data Layer**: Domain models representing course structure
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Parse Articulate Rise JSON data from URLs or local files
|
- Parse Articulate Rise JSON data from URLs or local files
|
||||||
- Export to Markdown (.md) format
|
- Export to Markdown (.md) format
|
||||||
- Export to HTML (.html) format with professional styling
|
- Export to HTML (.html) format with professional styling
|
||||||
- Export to Word Document (.docx) format
|
- Export to Word Document (.docx) format
|
||||||
- Support for various content types:
|
- Support for various content types:
|
||||||
- Text content with headings and paragraphs
|
- Text content with headings and paragraphs
|
||||||
- Lists and bullet points
|
- Lists and bullet points
|
||||||
- Multimedia content (videos and images)
|
- Multimedia content (videos and images)
|
||||||
- Knowledge checks and quizzes
|
- Knowledge checks and quizzes
|
||||||
- Interactive content (flashcards)
|
- Interactive content (flashcards)
|
||||||
- Course structure and metadata
|
- Course structure and metadata
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- 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.
|
- 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
|
### Install from source
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ go install github.com/kjanat/articulate-parser@latest
|
|||||||
|
|
||||||
The parser uses the following external library:
|
The parser uses the following external library:
|
||||||
|
|
||||||
- `github.com/fumiama/go-docx` - For creating Word documents (MIT license)
|
- `github.com/fumiama/go-docx` - For creating Word documents (MIT license)
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -164,25 +164,25 @@ go run main.go <input_uri_or_file> <output_format> [output_path]
|
|||||||
|
|
||||||
#### Examples
|
#### Examples
|
||||||
|
|
||||||
1. **Parse from URL and export to Markdown:**
|
1. **Parse from URL and export to Markdown:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run main.go "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/" md
|
go run main.go "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/" md
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Parse from local file and export to Word:**
|
2. **Parse from local file and export to Word:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run main.go "articulate-sample.json" docx "my-course.docx"
|
go run main.go "articulate-sample.json" docx "my-course.docx"
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Parse from local file and export to HTML:**
|
3. **Parse from local file and export to HTML:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run main.go "articulate-sample.json" html "output.html"
|
go run main.go "articulate-sample.json" html "output.html"
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Parse from local file and export to Markdown:**
|
4. **Parse from local file and export to Markdown:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run main.go "articulate-sample.json" md "output.md"
|
go run main.go "articulate-sample.json" md "output.md"
|
||||||
@@ -206,7 +206,7 @@ Then run:
|
|||||||
|
|
||||||
The application is available as a Docker image from GitHub Container Registry.
|
The application is available as a Docker image from GitHub Container Registry.
|
||||||
|
|
||||||
### 🐳 Docker Image Information
|
### Docker Image Information
|
||||||
|
|
||||||
- **Registry**: `ghcr.io/kjanat/articulate-parser`
|
- **Registry**: `ghcr.io/kjanat/articulate-parser`
|
||||||
- **Platforms**: linux/amd64, linux/arm64
|
- **Platforms**: linux/amd64, linux/arm64
|
||||||
@@ -225,14 +225,14 @@ docker run --rm ghcr.io/kjanat/articulate-parser:latest --help
|
|||||||
|
|
||||||
### Available Tags
|
### Available Tags
|
||||||
|
|
||||||
| Tag | Description | Use Case |
|
| Tag | Description | Use Case |
|
||||||
|-----|-------------|----------|
|
| --------------------- | ------------------------------------------- | ---------------------- |
|
||||||
| `latest` | Latest stable release from master branch | Production use |
|
| `latest` | Latest stable release from master branch | Production use |
|
||||||
| `edge` | Latest development build from master branch | Testing new features |
|
| `edge` | Latest development build from master branch | Testing new features |
|
||||||
| `v1.x.x` | Specific version releases | Production pinning |
|
| `v1.x.x` | Specific version releases | Production pinning |
|
||||||
| `develop` | Development branch builds | Development/testing |
|
| `develop` | Development branch builds | Development/testing |
|
||||||
| `feature/docker-ghcr` | Feature branch builds | Feature testing |
|
| `feature/docker-ghcr` | Feature branch builds | Feature testing |
|
||||||
| `master` | Latest master branch build | Continuous integration |
|
| `master` | Latest master branch build | Continuous integration |
|
||||||
|
|
||||||
### Usage Examples
|
### Usage Examples
|
||||||
|
|
||||||
@@ -313,11 +313,11 @@ docker build --build-arg VERSION=local --build-arg BUILD_TIME=$(date -u +%Y-%m-%
|
|||||||
|
|
||||||
The Docker image supports the following build-time arguments:
|
The Docker image supports the following build-time arguments:
|
||||||
|
|
||||||
| Argument | Description | Default |
|
| Argument | Description | Default |
|
||||||
|----------|-------------|---------|
|
| ------------ | ------------------------------------- | -------------- |
|
||||||
| `VERSION` | Version string embedded in the binary | `dev` |
|
| `VERSION` | Version string embedded in the binary | `dev` |
|
||||||
| `BUILD_TIME` | Build timestamp | Current time |
|
| `BUILD_TIME` | Build timestamp | Current time |
|
||||||
| `GIT_COMMIT` | Git commit hash | Current commit |
|
| `GIT_COMMIT` | Git commit hash | Current commit |
|
||||||
|
|
||||||
### Docker Security
|
### Docker Security
|
||||||
|
|
||||||
@@ -332,88 +332,88 @@ The Docker image supports the following build-time arguments:
|
|||||||
|
|
||||||
The project maintains high code quality standards:
|
The project maintains high code quality standards:
|
||||||
|
|
||||||
- Cyclomatic complexity ≤ 15 (checked with [gocyclo](https://github.com/fzipp/gocyclo))
|
- Cyclomatic complexity ≤ 15 (checked with [gocyclo](https://github.com/fzipp/gocyclo))
|
||||||
- Race condition detection enabled
|
- Race condition detection enabled
|
||||||
- Comprehensive test coverage
|
- Comprehensive test coverage
|
||||||
- Code formatting with `gofmt`
|
- Code formatting with `gofmt`
|
||||||
- Static analysis with `go vet`
|
- Static analysis with `go vet`
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create a feature branch
|
2. Create a feature branch
|
||||||
3. Make your changes
|
3. Make your changes
|
||||||
4. Run tests: `go test ./...`
|
4. Run tests: `go test ./...`
|
||||||
5. Submit a pull request
|
5. Submit a pull request
|
||||||
|
|
||||||
## Output Formats
|
## Output Formats
|
||||||
|
|
||||||
### Markdown (`.md`)
|
### Markdown (`.md`)
|
||||||
|
|
||||||
- Hierarchical structure with proper heading levels
|
- Hierarchical structure with proper heading levels
|
||||||
- Clean text content with HTML tags removed
|
- Clean text content with HTML tags removed
|
||||||
- Lists and bullet points preserved
|
- Lists and bullet points preserved
|
||||||
- Quiz questions with correct answers marked
|
- Quiz questions with correct answers marked
|
||||||
- Media references included
|
- Media references included
|
||||||
- Course metadata at the top
|
- Course metadata at the top
|
||||||
|
|
||||||
### HTML (`.html`)
|
### HTML (`.html`)
|
||||||
|
|
||||||
- Professional styling with embedded CSS
|
- Professional styling with embedded CSS
|
||||||
- Interactive and visually appealing layout
|
- Interactive and visually appealing layout
|
||||||
- Proper HTML structure with semantic elements
|
- Proper HTML structure with semantic elements
|
||||||
- Responsive design for different screen sizes
|
- Responsive design for different screen sizes
|
||||||
- All content types beautifully formatted
|
- All content types beautifully formatted
|
||||||
- Maintains course hierarchy and organization
|
- Maintains course hierarchy and organization
|
||||||
|
|
||||||
### Word Document (`.docx`)
|
### Word Document (`.docx`)
|
||||||
|
|
||||||
- Professional document formatting
|
- Professional document formatting
|
||||||
- Bold headings and proper typography
|
- Bold headings and proper typography
|
||||||
- Bulleted lists
|
- Bulleted lists
|
||||||
- Quiz questions with answers
|
- Quiz questions with answers
|
||||||
- Media content references
|
- Media content references
|
||||||
- Maintains course structure
|
- Maintains course structure
|
||||||
|
|
||||||
## Supported Content Types
|
## Supported Content Types
|
||||||
|
|
||||||
The parser handles the following Articulate Rise content types:
|
The parser handles the following Articulate Rise content types:
|
||||||
|
|
||||||
- **Text blocks**: Headings and paragraphs
|
- **Text blocks**: Headings and paragraphs
|
||||||
- **Lists**: Bullet points and numbered lists
|
- **Lists**: Bullet points and numbered lists
|
||||||
- **Multimedia**: Videos and images (references only)
|
- **Multimedia**: Videos and images (references only)
|
||||||
- **Knowledge Checks**: Multiple choice, multiple response, fill-in-the-blank, matching
|
- **Knowledge Checks**: Multiple choice, multiple response, fill-in-the-blank, matching
|
||||||
- **Interactive Content**: Flashcards and interactive scenarios
|
- **Interactive Content**: Flashcards and interactive scenarios
|
||||||
- **Dividers**: Section breaks
|
- **Dividers**: Section breaks
|
||||||
- **Sections**: Course organization
|
- **Sections**: Course organization
|
||||||
|
|
||||||
## Data Structure
|
## Data Structure
|
||||||
|
|
||||||
The parser works with the standard Articulate Rise JSON format which includes:
|
The parser works with the standard Articulate Rise JSON format which includes:
|
||||||
|
|
||||||
- Course metadata (title, description, settings)
|
- Course metadata (title, description, settings)
|
||||||
- Lesson structure
|
- Lesson structure
|
||||||
- Content items with various types
|
- Content items with various types
|
||||||
- Media references
|
- Media references
|
||||||
- Quiz/assessment data
|
- Quiz/assessment data
|
||||||
- Styling and layout information
|
- Styling and layout information
|
||||||
|
|
||||||
## URL Pattern Recognition
|
## URL Pattern Recognition
|
||||||
|
|
||||||
The parser automatically extracts share IDs from Articulate Rise URLs:
|
The parser automatically extracts share IDs from Articulate Rise URLs:
|
||||||
|
|
||||||
- Input: `https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/`
|
- Input: `https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/`
|
||||||
- API URL: `https://rise.articulate.com/api/rise-runtime/boot/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO`
|
- API URL: `https://rise.articulate.com/api/rise-runtime/boot/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO`
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
The parser includes error handling for:
|
The parser includes error handling for:
|
||||||
|
|
||||||
- Invalid URLs or share IDs
|
- Invalid URLs or share IDs
|
||||||
- Network connection issues
|
- Network connection issues
|
||||||
- Malformed JSON data
|
- Malformed JSON data
|
||||||
- File I/O errors
|
- File I/O errors
|
||||||
- Unsupported content types
|
- Unsupported content types
|
||||||
|
|
||||||
<!-- ## Code coverage
|
<!-- ## Code coverage
|
||||||
|
|
||||||
@@ -425,28 +425,28 @@ The parser includes error handling for:
|
|||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- Media files (videos, images) are referenced but not downloaded
|
- Media files (videos, images) are referenced but not downloaded
|
||||||
- Complex interactive elements may be simplified in export
|
- Complex interactive elements may be simplified in export
|
||||||
- Styling and visual formatting is not preserved
|
- Styling and visual formatting is not preserved
|
||||||
- Assessment logic and interactivity is lost in static exports
|
- Assessment logic and interactivity is lost in static exports
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
- Lightweight with minimal dependencies
|
- Lightweight with minimal dependencies
|
||||||
- Fast JSON parsing and export
|
- Fast JSON parsing and export
|
||||||
- Memory efficient processing
|
- Memory efficient processing
|
||||||
- No external license requirements
|
- No external license requirements
|
||||||
|
|
||||||
## Future Enhancements
|
## Future Enhancements
|
||||||
|
|
||||||
Potential improvements could include:
|
Potential improvements could include:
|
||||||
|
|
||||||
- [ ] PDF export support
|
- [ ] PDF export support
|
||||||
- [ ] Media file downloading
|
- [ ] Media file downloading
|
||||||
- [x] ~~HTML export with preserved styling~~
|
- [x] ~~HTML export with preserved styling~~
|
||||||
- [ ] SCORM package support
|
- [ ] SCORM package support
|
||||||
- [ ] Batch processing capabilities
|
- [ ] Batch processing capabilities
|
||||||
- [ ] Custom template support
|
- [ ] Custom template support
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -460,7 +460,9 @@ This is a utility tool for educational content conversion. Please ensure you hav
|
|||||||
[Go report]: https://goreportcard.com/report/github.com/kjanat/articulate-parser
|
[Go report]: https://goreportcard.com/report/github.com/kjanat/articulate-parser
|
||||||
[gomod]: go.mod
|
[gomod]: go.mod
|
||||||
[Issues]: https://github.com/kjanat/articulate-parser/issues
|
[Issues]: https://github.com/kjanat/articulate-parser/issues
|
||||||
|
|
||||||
<!-- [Latest release]: https://github.com/kjanat/articulate-parser/releases/latest -->
|
<!-- [Latest release]: https://github.com/kjanat/articulate-parser/releases/latest -->
|
||||||
|
|
||||||
[MIT License]: LICENSE
|
[MIT License]: LICENSE
|
||||||
[Package documentation]: https://godoc.org/github.com/kjanat/articulate-parser
|
[Package documentation]: https://godoc.org/github.com/kjanat/articulate-parser
|
||||||
[Tags]: https://github.com/kjanat/articulate-parser/tags
|
[Tags]: https://github.com/kjanat/articulate-parser/tags
|
||||||
|
|||||||
+39
-31
@@ -1,7 +1,7 @@
|
|||||||
# yaml-language-server: $schema=https://taskfile.dev/schema.json
|
# yaml-language-server: $schema=https://taskfile.dev/schema.json
|
||||||
# Articulate Parser - Task Automation
|
# Articulate Parser - Task Automation
|
||||||
# https://taskfile.dev
|
# https://taskfile.dev
|
||||||
version: '3'
|
version: "3"
|
||||||
|
|
||||||
# Global output settings
|
# Global output settings
|
||||||
output: prefixed
|
output: prefixed
|
||||||
@@ -47,11 +47,11 @@ vars:
|
|||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: '{{.CGO_ENABLED}}'
|
CGO_ENABLED: "{{.CGO_ENABLED}}"
|
||||||
GO111MODULE: on
|
GO111MODULE: on
|
||||||
|
|
||||||
# Load .env files if present
|
# Load .env files if present
|
||||||
dotenv: ['.env', '.env.local']
|
dotenv: [".env", ".env.local"]
|
||||||
|
|
||||||
# Task definitions
|
# Task definitions
|
||||||
tasks:
|
tasks:
|
||||||
@@ -69,12 +69,12 @@ tasks:
|
|||||||
interactive: true
|
interactive: true
|
||||||
watch: true
|
watch: true
|
||||||
sources:
|
sources:
|
||||||
- '**/*.go'
|
- "**/*.go"
|
||||||
- go.mod
|
- go.mod
|
||||||
- go.sum
|
- go.sum
|
||||||
cmds:
|
cmds:
|
||||||
- task: build
|
- task: build
|
||||||
- '{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} --help'
|
- "{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} --help"
|
||||||
|
|
||||||
# Build tasks
|
# Build tasks
|
||||||
build:
|
build:
|
||||||
@@ -82,14 +82,14 @@ tasks:
|
|||||||
aliases: [b]
|
aliases: [b]
|
||||||
deps: [clean-bin]
|
deps: [clean-bin]
|
||||||
sources:
|
sources:
|
||||||
- '**/*.go'
|
- "**/*.go"
|
||||||
- go.mod
|
- go.mod
|
||||||
- go.sum
|
- go.sum
|
||||||
generates:
|
generates:
|
||||||
- '{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}}'
|
- "{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}}"
|
||||||
cmds:
|
cmds:
|
||||||
- task: mkdir
|
- task: mkdir
|
||||||
vars: { DIR: '{{.OUTPUT_DIR}}' }
|
vars: { DIR: "{{.OUTPUT_DIR}}" }
|
||||||
- go build {{.GO_FLAGS}} -ldflags="{{.LDFLAGS}}" -o {{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} {{.MAIN_FILE}}
|
- go build {{.GO_FLAGS}} -ldflags="{{.LDFLAGS}}" -o {{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} {{.MAIN_FILE}}
|
||||||
method: checksum
|
method: checksum
|
||||||
|
|
||||||
@@ -99,25 +99,25 @@ tasks:
|
|||||||
deps: [clean-bin]
|
deps: [clean-bin]
|
||||||
cmds:
|
cmds:
|
||||||
- task: mkdir
|
- task: mkdir
|
||||||
vars: { DIR: '{{.OUTPUT_DIR}}' }
|
vars: { DIR: "{{.OUTPUT_DIR}}" }
|
||||||
- for:
|
- for:
|
||||||
matrix:
|
matrix:
|
||||||
GOOS: [linux, darwin, windows]
|
GOOS: [linux, darwin, windows]
|
||||||
GOARCH: [amd64, arm64]
|
GOARCH: [amd64, arm64]
|
||||||
task: build:platform
|
task: build:platform
|
||||||
vars:
|
vars:
|
||||||
TARGET_GOOS: '{{.ITEM.GOOS}}'
|
TARGET_GOOS: "{{.ITEM.GOOS}}"
|
||||||
TARGET_GOARCH: '{{.ITEM.GOARCH}}'
|
TARGET_GOARCH: "{{.ITEM.GOARCH}}"
|
||||||
- echo "Built binaries for all platforms in {{.OUTPUT_DIR}}/"
|
- echo "Built binaries for all platforms in {{.OUTPUT_DIR}}/"
|
||||||
|
|
||||||
build:platform:
|
build:platform:
|
||||||
internal: true
|
internal: true
|
||||||
vars:
|
vars:
|
||||||
TARGET_EXT: '{{if eq .TARGET_GOOS "windows"}}.exe{{end}}'
|
TARGET_EXT: '{{if eq .TARGET_GOOS "windows"}}.exe{{end}}'
|
||||||
OUTPUT_FILE: '{{.OUTPUT_DIR}}/{{.APP_NAME}}-{{.TARGET_GOOS}}-{{.TARGET_GOARCH}}{{.TARGET_EXT}}'
|
OUTPUT_FILE: "{{.OUTPUT_DIR}}/{{.APP_NAME}}-{{.TARGET_GOOS}}-{{.TARGET_GOARCH}}{{.TARGET_EXT}}"
|
||||||
env:
|
env:
|
||||||
GOOS: '{{.TARGET_GOOS}}'
|
GOOS: "{{.TARGET_GOOS}}"
|
||||||
GOARCH: '{{.TARGET_GOARCH}}'
|
GOARCH: "{{.TARGET_GOARCH}}"
|
||||||
cmds:
|
cmds:
|
||||||
- echo "Building {{.OUTPUT_FILE}}..."
|
- echo "Building {{.OUTPUT_FILE}}..."
|
||||||
- go build {{.GO_FLAGS}} -ldflags="{{.LDFLAGS}}" -o "{{.OUTPUT_FILE}}" {{.MAIN_FILE}}
|
- go build {{.GO_FLAGS}} -ldflags="{{.LDFLAGS}}" -o "{{.OUTPUT_FILE}}" {{.MAIN_FILE}}
|
||||||
@@ -134,6 +134,8 @@ tasks:
|
|||||||
test:
|
test:
|
||||||
desc: Run all tests
|
desc: Run all tests
|
||||||
aliases: [t]
|
aliases: [t]
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 1
|
||||||
cmds:
|
cmds:
|
||||||
- go test {{.GO_FLAGS}} -race -timeout {{.TEST_TIMEOUT}} ./...
|
- go test {{.GO_FLAGS}} -race -timeout {{.TEST_TIMEOUT}} ./...
|
||||||
|
|
||||||
@@ -141,9 +143,11 @@ tasks:
|
|||||||
desc: Run tests with coverage report
|
desc: Run tests with coverage report
|
||||||
aliases: [cover, cov]
|
aliases: [cover, cov]
|
||||||
deps: [clean-coverage]
|
deps: [clean-coverage]
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 1
|
||||||
cmds:
|
cmds:
|
||||||
- task: mkdir
|
- task: mkdir
|
||||||
vars: { DIR: '{{.COVERAGE_DIR}}' }
|
vars: { DIR: "{{.COVERAGE_DIR}}" }
|
||||||
- go test {{.GO_FLAGS}} -race -coverprofile={{.COVERAGE_DIR}}/coverage.out -covermode=atomic -timeout {{.TEST_TIMEOUT}} ./...
|
- go test {{.GO_FLAGS}} -race -coverprofile={{.COVERAGE_DIR}}/coverage.out -covermode=atomic -timeout {{.TEST_TIMEOUT}} ./...
|
||||||
- go tool cover -html={{.COVERAGE_DIR}}/coverage.out -o {{.COVERAGE_DIR}}/coverage.html
|
- go tool cover -html={{.COVERAGE_DIR}}/coverage.out -o {{.COVERAGE_DIR}}/coverage.html
|
||||||
- go tool cover -func={{.COVERAGE_DIR}}/coverage.out
|
- go tool cover -func={{.COVERAGE_DIR}}/coverage.out
|
||||||
@@ -152,6 +156,8 @@ tasks:
|
|||||||
test:verbose:
|
test:verbose:
|
||||||
desc: Run tests with verbose output
|
desc: Run tests with verbose output
|
||||||
aliases: [tv]
|
aliases: [tv]
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 1
|
||||||
cmds:
|
cmds:
|
||||||
- go test -v -race -timeout {{.TEST_TIMEOUT}} ./...
|
- go test -v -race -timeout {{.TEST_TIMEOUT}} ./...
|
||||||
|
|
||||||
@@ -160,7 +166,7 @@ tasks:
|
|||||||
aliases: [tw]
|
aliases: [tw]
|
||||||
watch: true
|
watch: true
|
||||||
sources:
|
sources:
|
||||||
- '**/*.go'
|
- "**/*.go"
|
||||||
cmds:
|
cmds:
|
||||||
- task: test
|
- task: test
|
||||||
|
|
||||||
@@ -172,6 +178,8 @@ tasks:
|
|||||||
|
|
||||||
test:integration:
|
test:integration:
|
||||||
desc: Run integration tests
|
desc: Run integration tests
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 1
|
||||||
status:
|
status:
|
||||||
- '{{if eq OS "windows"}}if not exist "main_test.go" exit 1{{else}}test ! -f "main_test.go"{{end}}'
|
- '{{if eq OS "windows"}}if not exist "main_test.go" exit 1{{else}}test ! -f "main_test.go"{{end}}'
|
||||||
cmds:
|
cmds:
|
||||||
@@ -352,14 +360,14 @@ tasks:
|
|||||||
internal: true
|
internal: true
|
||||||
cmds:
|
cmds:
|
||||||
- task: rmdir
|
- task: rmdir
|
||||||
vars: { DIR: '{{.OUTPUT_DIR}}' }
|
vars: { DIR: "{{.OUTPUT_DIR}}" }
|
||||||
|
|
||||||
clean-coverage:
|
clean-coverage:
|
||||||
desc: Remove coverage files
|
desc: Remove coverage files
|
||||||
internal: true
|
internal: true
|
||||||
cmds:
|
cmds:
|
||||||
- task: rmdir
|
- task: rmdir
|
||||||
vars: { DIR: '{{.COVERAGE_DIR}}' }
|
vars: { DIR: "{{.COVERAGE_DIR}}" }
|
||||||
|
|
||||||
clean-cache:
|
clean-cache:
|
||||||
desc: Clean Go build and test cache
|
desc: Clean Go build and test cache
|
||||||
@@ -410,10 +418,10 @@ tasks:
|
|||||||
requires:
|
requires:
|
||||||
vars: [VERSION]
|
vars: [VERSION]
|
||||||
preconditions:
|
preconditions:
|
||||||
- sh: 'git diff --exit-code'
|
- sh: "git diff --exit-code"
|
||||||
msg: 'Working directory is not clean'
|
msg: "Working directory is not clean"
|
||||||
- sh: 'git diff --cached --exit-code'
|
- sh: "git diff --cached --exit-code"
|
||||||
msg: 'Staging area is not clean'
|
msg: "Staging area is not clean"
|
||||||
cmds:
|
cmds:
|
||||||
- git tag -a v{{.VERSION}} -m "Release v{{.VERSION}}"
|
- git tag -a v{{.VERSION}} -m "Release v{{.VERSION}}"
|
||||||
- echo "Tagged v{{.VERSION}}"
|
- echo "Tagged v{{.VERSION}}"
|
||||||
@@ -498,11 +506,11 @@ tasks:
|
|||||||
- '{{if eq OS "windows"}}if not exist "articulate-sample.json" exit 1{{else}}test ! -f "articulate-sample.json"{{end}}'
|
- '{{if eq OS "windows"}}if not exist "articulate-sample.json" exit 1{{else}}test ! -f "articulate-sample.json"{{end}}'
|
||||||
deps: [build]
|
deps: [build]
|
||||||
cmds:
|
cmds:
|
||||||
- '{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} articulate-sample.json md output-demo.md'
|
- "{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} articulate-sample.json md output-demo.md"
|
||||||
- echo "Demo Markdown created{{:}} output-demo.md"
|
- echo "Demo Markdown created{{:}} output-demo.md"
|
||||||
- defer:
|
- defer:
|
||||||
task: rmfile
|
task: rmfile
|
||||||
vars: { FILE: 'output-demo.md' }
|
vars: { FILE: "output-demo.md" }
|
||||||
|
|
||||||
demo:html:
|
demo:html:
|
||||||
desc: Demo - Convert sample to HTML
|
desc: Demo - Convert sample to HTML
|
||||||
@@ -510,11 +518,11 @@ tasks:
|
|||||||
- '{{if eq OS "windows"}}if not exist "articulate-sample.json" exit 1{{else}}test ! -f "articulate-sample.json"{{end}}'
|
- '{{if eq OS "windows"}}if not exist "articulate-sample.json" exit 1{{else}}test ! -f "articulate-sample.json"{{end}}'
|
||||||
deps: [build]
|
deps: [build]
|
||||||
cmds:
|
cmds:
|
||||||
- '{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} articulate-sample.json html output-demo.html'
|
- "{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} articulate-sample.json html output-demo.html"
|
||||||
- echo "Demo HTML created{{:}} output-demo.html"
|
- echo "Demo HTML created{{:}} output-demo.html"
|
||||||
- defer:
|
- defer:
|
||||||
task: rmfile
|
task: rmfile
|
||||||
vars: { FILE: 'output-demo.html' }
|
vars: { FILE: "output-demo.html" }
|
||||||
|
|
||||||
demo:docx:
|
demo:docx:
|
||||||
desc: Demo - Convert sample to DOCX
|
desc: Demo - Convert sample to DOCX
|
||||||
@@ -522,11 +530,11 @@ tasks:
|
|||||||
- '{{if eq OS "windows"}}if not exist "articulate-sample.json" exit 1{{else}}test ! -f "articulate-sample.json"{{end}}'
|
- '{{if eq OS "windows"}}if not exist "articulate-sample.json" exit 1{{else}}test ! -f "articulate-sample.json"{{end}}'
|
||||||
deps: [build]
|
deps: [build]
|
||||||
cmds:
|
cmds:
|
||||||
- '{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} articulate-sample.json docx output-demo.docx'
|
- "{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} articulate-sample.json docx output-demo.docx"
|
||||||
- echo "Demo DOCX created{{:}} output-demo.docx"
|
- echo "Demo DOCX created{{:}} output-demo.docx"
|
||||||
- defer:
|
- defer:
|
||||||
task: rmfile
|
task: rmfile
|
||||||
vars: { FILE: 'output-demo.docx' }
|
vars: { FILE: "output-demo.docx" }
|
||||||
|
|
||||||
# Performance profiling
|
# Performance profiling
|
||||||
profile:cpu:
|
profile:cpu:
|
||||||
@@ -536,7 +544,7 @@ tasks:
|
|||||||
- go tool pprof -http=:8080 cpu.prof
|
- go tool pprof -http=:8080 cpu.prof
|
||||||
- defer:
|
- defer:
|
||||||
task: rmfile
|
task: rmfile
|
||||||
vars: { FILE: 'cpu.prof' }
|
vars: { FILE: "cpu.prof" }
|
||||||
|
|
||||||
profile:mem:
|
profile:mem:
|
||||||
desc: Run memory profiling
|
desc: Run memory profiling
|
||||||
@@ -545,14 +553,14 @@ tasks:
|
|||||||
- go tool pprof -http=:8080 mem.prof
|
- go tool pprof -http=:8080 mem.prof
|
||||||
- defer:
|
- defer:
|
||||||
task: rmfile
|
task: rmfile
|
||||||
vars: { FILE: 'mem.prof' }
|
vars: { FILE: "mem.prof" }
|
||||||
|
|
||||||
# Git hooks
|
# Git hooks
|
||||||
hooks:install:
|
hooks:install:
|
||||||
desc: Install git hooks
|
desc: Install git hooks
|
||||||
cmds:
|
cmds:
|
||||||
- task: mkdir
|
- task: mkdir
|
||||||
vars: { DIR: '.git/hooks' }
|
vars: { DIR: ".git/hooks" }
|
||||||
- '{{if eq OS "windows"}}echo "#!/bin/sh" > .git/hooks/pre-commit && echo "task lint:fmt" >> .git/hooks/pre-commit{{else}}cat > .git/hooks/pre-commit << ''EOF''{{printf "\n"}}#!/bin/sh{{printf "\n"}}task lint:fmt{{printf "\n"}}EOF{{printf "\n"}}chmod +x .git/hooks/pre-commit{{end}}'
|
- '{{if eq OS "windows"}}echo "#!/bin/sh" > .git/hooks/pre-commit && echo "task lint:fmt" >> .git/hooks/pre-commit{{else}}cat > .git/hooks/pre-commit << ''EOF''{{printf "\n"}}#!/bin/sh{{printf "\n"}}task lint:fmt{{printf "\n"}}EOF{{printf "\n"}}chmod +x .git/hooks/pre-commit{{end}}'
|
||||||
- echo "Git hooks installed"
|
- echo "Git hooks installed"
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
module github.com/kjanat/articulate-parser
|
module github.com/kjanat/articulate-parser
|
||||||
|
|
||||||
go 1.24.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b
|
github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b
|
||||||
golang.org/x/net v0.46.0
|
golang.org/x/net v0.59.0
|
||||||
golang.org/x/text v0.30.0
|
golang.org/x/text v0.42.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fumiama/imgsz v0.0.4 // indirect
|
github.com/fumiama/imgsz v0.0.4 // indirect
|
||||||
golang.org/x/image v0.32.0 // indirect
|
golang.org/x/image v0.42.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b h1:/mxSugRc4SgN7Xg
|
|||||||
github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b/go.mod h1:ssRF0IaB1hCcKIObp3FkZOsjTcAHpgii70JelNb4H8M=
|
github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b/go.mod h1:ssRF0IaB1hCcKIObp3FkZOsjTcAHpgii70JelNb4H8M=
|
||||||
github.com/fumiama/imgsz v0.0.4 h1:Lsasu2hdSSFS+vnD+nvR1UkiRMK7hcpyYCC0FzgSMFI=
|
github.com/fumiama/imgsz v0.0.4 h1:Lsasu2hdSSFS+vnD+nvR1UkiRMK7hcpyYCC0FzgSMFI=
|
||||||
github.com/fumiama/imgsz v0.0.4/go.mod h1:bISOQVTlw9sRytPwe8ir7tAaEmyz9hSNj9n8mXMBG0E=
|
github.com/fumiama/imgsz v0.0.4/go.mod h1:bISOQVTlw9sRytPwe8ir7tAaEmyz9hSNj9n8mXMBG0E=
|
||||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Package apperrors provides sentinel errors for the articulate-parser application.
|
||||||
|
package apperrors
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Export errors.
|
||||||
|
var (
|
||||||
|
// ErrUnsupportedFormat is returned when an unknown export format is requested.
|
||||||
|
ErrUnsupportedFormat = errors.New("unsupported export format")
|
||||||
|
)
|
||||||
|
|
||||||
|
// URI parsing errors.
|
||||||
|
var (
|
||||||
|
// ErrInvalidURI is returned when a URI cannot be parsed.
|
||||||
|
ErrInvalidURI = errors.New("invalid URI")
|
||||||
|
|
||||||
|
// ErrInvalidDomain is returned when the URI domain is not an Articulate Rise domain.
|
||||||
|
ErrInvalidDomain = errors.New("invalid domain for Articulate Rise URI")
|
||||||
|
|
||||||
|
// ErrNoShareID is returned when a share ID cannot be extracted from the URI.
|
||||||
|
ErrNoShareID = errors.New("could not extract share ID from URI")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Operation errors.
|
||||||
|
var (
|
||||||
|
// ErrOperationCanceled is returned when an operation is canceled via context.
|
||||||
|
ErrOperationCanceled = errors.New("operation canceled")
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package apperrors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSentinelErrors_Unwrap(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sentinel error
|
||||||
|
wrapped error
|
||||||
|
}{
|
||||||
|
{"ErrUnsupportedFormat", ErrUnsupportedFormat, fmt.Errorf("%w: xyz", ErrUnsupportedFormat)},
|
||||||
|
{"ErrInvalidURI", ErrInvalidURI, fmt.Errorf("%w: bad", ErrInvalidURI)},
|
||||||
|
{"ErrInvalidDomain", ErrInvalidDomain, fmt.Errorf("%w: evil.com", ErrInvalidDomain)},
|
||||||
|
{"ErrNoShareID", ErrNoShareID, fmt.Errorf("%w: no match", ErrNoShareID)},
|
||||||
|
{"ErrOperationCanceled", ErrOperationCanceled, fmt.Errorf("%w: ctx done", ErrOperationCanceled)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if !errors.Is(tt.wrapped, tt.sentinel) {
|
||||||
|
t.Errorf("errors.Is(%v, %v) = false, want true", tt.wrapped, tt.sentinel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSentinelErrors_Message(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
err error
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{ErrUnsupportedFormat, "unsupported export format"},
|
||||||
|
{ErrInvalidURI, "invalid URI"},
|
||||||
|
{ErrInvalidDomain, "invalid domain for Articulate Rise URI"},
|
||||||
|
{ErrNoShareID, "could not extract share ID from URI"},
|
||||||
|
{ErrOperationCanceled, "operation canceled"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.expected, func(t *testing.T) {
|
||||||
|
if tt.err.Error() != tt.expected {
|
||||||
|
t.Errorf("got %q, want %q", tt.err.Error(), tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package exporters
|
||||||
|
|
||||||
|
import "github.com/kjanat/articulate-parser/internal/interfaces"
|
||||||
|
|
||||||
|
// baseExporter contains common fields shared by all exporter implementations.
|
||||||
|
// Embed this struct in concrete exporters to inherit HTMLCleaner and Logger.
|
||||||
|
type baseExporter struct {
|
||||||
|
// htmlCleaner is used to convert HTML content to plain text.
|
||||||
|
htmlCleaner interfaces.HTMLCleaner
|
||||||
|
// logger is used for logging warnings and errors.
|
||||||
|
logger interfaces.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBaseExporter creates a new baseExporter with the given dependencies.
|
||||||
|
func newBaseExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) baseExporter {
|
||||||
|
return baseExporter{
|
||||||
|
htmlCleaner: htmlCleaner,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,13 @@ import (
|
|||||||
|
|
||||||
"github.com/kjanat/articulate-parser/internal/models"
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
"github.com/kjanat/articulate-parser/internal/services"
|
"github.com/kjanat/articulate-parser/internal/services"
|
||||||
|
"github.com/kjanat/articulate-parser/internal/testdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BenchmarkFactory_CreateExporter_Markdown benchmarks markdown exporter creation.
|
// BenchmarkFactory_CreateExporter_Markdown benchmarks markdown exporter creation.
|
||||||
func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) {
|
func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for b.Loop() {
|
for b.Loop() {
|
||||||
@@ -22,7 +23,7 @@ func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) {
|
|||||||
// BenchmarkFactory_CreateExporter_All benchmarks creating all exporter types.
|
// BenchmarkFactory_CreateExporter_All benchmarks creating all exporter types.
|
||||||
func BenchmarkFactory_CreateExporter_All(b *testing.B) {
|
func BenchmarkFactory_CreateExporter_All(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
formats := []string{"markdown", "docx", "html"}
|
formats := []string{"markdown", "docx", "html"}
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
@@ -36,15 +37,15 @@ func BenchmarkFactory_CreateExporter_All(b *testing.B) {
|
|||||||
// BenchmarkAllExporters_Export benchmarks all exporters with the same course.
|
// BenchmarkAllExporters_Export benchmarks all exporters with the same course.
|
||||||
func BenchmarkAllExporters_Export(b *testing.B) {
|
func BenchmarkAllExporters_Export(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
course := createBenchmarkCourse()
|
course := testdata.BasicCourse()
|
||||||
|
|
||||||
exporters := map[string]struct {
|
exporters := map[string]struct {
|
||||||
exporter any
|
exporter any
|
||||||
ext string
|
ext string
|
||||||
}{
|
}{
|
||||||
"Markdown": {NewMarkdownExporter(htmlCleaner), ".md"},
|
"Markdown": {NewMarkdownExporter(htmlCleaner, nil), ".md"},
|
||||||
"Docx": {NewDocxExporter(htmlCleaner), ".docx"},
|
"Docx": {NewDocxExporter(htmlCleaner, nil), ".docx"},
|
||||||
"HTML": {NewHTMLExporter(htmlCleaner), ".html"},
|
"HTML": {NewHTMLExporter(htmlCleaner, nil), ".html"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, exp := range exporters {
|
for name, exp := range exporters {
|
||||||
@@ -66,10 +67,10 @@ func BenchmarkAllExporters_Export(b *testing.B) {
|
|||||||
// BenchmarkExporters_LargeCourse benchmarks exporters with large course data.
|
// BenchmarkExporters_LargeCourse benchmarks exporters with large course data.
|
||||||
func BenchmarkExporters_LargeCourse(b *testing.B) {
|
func BenchmarkExporters_LargeCourse(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
course := createLargeBenchmarkCourse()
|
course := testdata.LargeCourse(50)
|
||||||
|
|
||||||
b.Run("Markdown_Large", func(b *testing.B) {
|
b.Run("Markdown_Large", func(b *testing.B) {
|
||||||
exporter := NewMarkdownExporter(htmlCleaner)
|
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||||
tempDir := b.TempDir()
|
tempDir := b.TempDir()
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
@@ -80,7 +81,7 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
b.Run("Docx_Large", func(b *testing.B) {
|
b.Run("Docx_Large", func(b *testing.B) {
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
tempDir := b.TempDir()
|
tempDir := b.TempDir()
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
@@ -91,7 +92,7 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
b.Run("HTML_Large", func(b *testing.B) {
|
b.Run("HTML_Large", func(b *testing.B) {
|
||||||
exporter := NewHTMLExporter(htmlCleaner)
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
tempDir := b.TempDir()
|
tempDir := b.TempDir()
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
@@ -101,100 +102,3 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// createBenchmarkCourse creates a standard-sized course for benchmarking.
|
|
||||||
func createBenchmarkCourse() *models.Course {
|
|
||||||
return &models.Course{
|
|
||||||
ShareID: "benchmark-id",
|
|
||||||
Author: "Benchmark Author",
|
|
||||||
Course: models.CourseInfo{
|
|
||||||
ID: "bench-course",
|
|
||||||
Title: "Benchmark Course",
|
|
||||||
Description: "Performance testing course",
|
|
||||||
NavigationMode: "menu",
|
|
||||||
Lessons: []models.Lesson{
|
|
||||||
{
|
|
||||||
ID: "lesson1",
|
|
||||||
Title: "Introduction",
|
|
||||||
Type: "lesson",
|
|
||||||
Items: []models.Item{
|
|
||||||
{
|
|
||||||
Type: "text",
|
|
||||||
Items: []models.SubItem{
|
|
||||||
{
|
|
||||||
Heading: "Welcome",
|
|
||||||
Paragraph: "<p>This is a test paragraph with <strong>HTML</strong> content.</p>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: "list",
|
|
||||||
Items: []models.SubItem{
|
|
||||||
{Paragraph: "Item 1"},
|
|
||||||
{Paragraph: "Item 2"},
|
|
||||||
{Paragraph: "Item 3"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createLargeBenchmarkCourse creates a large course for stress testing.
|
|
||||||
func createLargeBenchmarkCourse() *models.Course {
|
|
||||||
lessons := make([]models.Lesson, 50)
|
|
||||||
for i := range 50 {
|
|
||||||
lessons[i] = models.Lesson{
|
|
||||||
ID: string(rune(i)),
|
|
||||||
Title: "Lesson " + string(rune(i)),
|
|
||||||
Type: "lesson",
|
|
||||||
Description: "<p>This is lesson description with <em>formatting</em>.</p>",
|
|
||||||
Items: []models.Item{
|
|
||||||
{
|
|
||||||
Type: "text",
|
|
||||||
Items: []models.SubItem{
|
|
||||||
{
|
|
||||||
Heading: "Section Heading",
|
|
||||||
Paragraph: "<p>Content with <strong>bold</strong> and <em>italic</em> text.</p>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: "list",
|
|
||||||
Items: []models.SubItem{
|
|
||||||
{Paragraph: "Point 1"},
|
|
||||||
{Paragraph: "Point 2"},
|
|
||||||
{Paragraph: "Point 3"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: "knowledgeCheck",
|
|
||||||
Items: []models.SubItem{
|
|
||||||
{
|
|
||||||
Title: "Quiz Question",
|
|
||||||
Answers: []models.Answer{
|
|
||||||
{Title: "Answer A", Correct: false},
|
|
||||||
{Title: "Answer B", Correct: true},
|
|
||||||
{Title: "Answer C", Correct: false},
|
|
||||||
},
|
|
||||||
Feedback: "Good job!",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &models.Course{
|
|
||||||
ShareID: "large-benchmark-id",
|
|
||||||
Author: "Benchmark Author",
|
|
||||||
Course: models.CourseInfo{
|
|
||||||
ID: "large-bench-course",
|
|
||||||
Title: "Large Benchmark Course",
|
|
||||||
Description: "Large performance testing course",
|
|
||||||
Lessons: lessons,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+16
-10
@@ -13,28 +13,34 @@ import (
|
|||||||
|
|
||||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||||
"github.com/kjanat/articulate-parser/internal/models"
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
"github.com/kjanat/articulate-parser/internal/services"
|
)
|
||||||
|
|
||||||
|
// Font sizes for DOCX document headings (in half-points, so "32" = 16pt).
|
||||||
|
const (
|
||||||
|
docxTitleSize = "32" // Course title (16pt)
|
||||||
|
docxLessonSize = "28" // Lesson heading (14pt)
|
||||||
|
docxItemSize = "24" // Item heading (12pt)
|
||||||
)
|
)
|
||||||
|
|
||||||
// DocxExporter implements the Exporter interface for DOCX format.
|
// DocxExporter implements the Exporter interface for DOCX format.
|
||||||
// It converts Articulate Rise course data into a Microsoft Word document
|
// It converts Articulate Rise course data into a Microsoft Word document
|
||||||
// using the go-docx package.
|
// using the go-docx package.
|
||||||
type DocxExporter struct {
|
type DocxExporter struct {
|
||||||
// htmlCleaner is used to convert HTML content to plain text
|
baseExporter
|
||||||
htmlCleaner *services.HTMLCleaner
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDocxExporter creates a new DocxExporter instance.
|
// NewDocxExporter creates a new DocxExporter instance.
|
||||||
// It takes an HTMLCleaner to handle HTML content conversion.
|
// It takes an HTMLCleaner to handle HTML content conversion and a logger for logging.
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - htmlCleaner: Service for cleaning HTML content in course data
|
// - htmlCleaner: Service for cleaning HTML content in course data
|
||||||
|
// - logger: Logger instance for warnings and errors
|
||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - An implementation of the Exporter interface for DOCX format
|
// - An implementation of the Exporter interface for DOCX format
|
||||||
func NewDocxExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
|
func NewDocxExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
|
||||||
return &DocxExporter{
|
return &DocxExporter{
|
||||||
htmlCleaner: htmlCleaner,
|
baseExporter: newBaseExporter(htmlCleaner, logger),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +59,7 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
|
|||||||
|
|
||||||
// Add title
|
// Add title
|
||||||
titlePara := doc.AddParagraph()
|
titlePara := doc.AddParagraph()
|
||||||
titlePara.AddText(course.Course.Title).Size("32").Bold()
|
titlePara.AddText(course.Course.Title).Size(docxTitleSize).Bold()
|
||||||
|
|
||||||
// Add description if available
|
// Add description if available
|
||||||
if course.Course.Description != "" {
|
if course.Course.Description != "" {
|
||||||
@@ -84,7 +90,7 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
|
|||||||
// affect the validity of the exported file.
|
// affect the validity of the exported file.
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := file.Close(); err != nil {
|
if err := file.Close(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "warning: failed to close output file: %v\n", err)
|
e.logger.Warn("failed to close output file", "error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -106,7 +112,7 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
|
|||||||
func (e *DocxExporter) exportLesson(doc *docx.Docx, lesson *models.Lesson) {
|
func (e *DocxExporter) exportLesson(doc *docx.Docx, lesson *models.Lesson) {
|
||||||
// Add lesson title
|
// Add lesson title
|
||||||
lessonPara := doc.AddParagraph()
|
lessonPara := doc.AddParagraph()
|
||||||
lessonPara.AddText(fmt.Sprintf("Lesson: %s", lesson.Title)).Size("28").Bold()
|
lessonPara.AddText(fmt.Sprintf("Lesson: %s", lesson.Title)).Size(docxLessonSize).Bold()
|
||||||
|
|
||||||
// Add lesson description if available
|
// Add lesson description if available
|
||||||
if lesson.Description != "" {
|
if lesson.Description != "" {
|
||||||
@@ -132,7 +138,7 @@ func (e *DocxExporter) exportItem(doc *docx.Docx, item *models.Item) {
|
|||||||
if item.Type != "" {
|
if item.Type != "" {
|
||||||
itemPara := doc.AddParagraph()
|
itemPara := doc.AddParagraph()
|
||||||
caser := cases.Title(language.English)
|
caser := cases.Title(language.English)
|
||||||
itemPara.AddText(caser.String(item.Type)).Size("24").Bold()
|
itemPara.AddText(caser.String(item.Type)).Size(docxItemSize).Bold()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add sub-items
|
// Add sub-items
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
// TestNewDocxExporter tests the NewDocxExporter constructor.
|
// TestNewDocxExporter tests the NewDocxExporter constructor.
|
||||||
func TestNewDocxExporter(t *testing.T) {
|
func TestNewDocxExporter(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
if exporter == nil {
|
if exporter == nil {
|
||||||
t.Fatal("NewDocxExporter() returned nil")
|
t.Fatal("NewDocxExporter() returned nil")
|
||||||
@@ -32,7 +32,7 @@ func TestNewDocxExporter(t *testing.T) {
|
|||||||
// TestDocxExporter_SupportedFormat tests the SupportedFormat method.
|
// TestDocxExporter_SupportedFormat tests the SupportedFormat method.
|
||||||
func TestDocxExporter_SupportedFormat(t *testing.T) {
|
func TestDocxExporter_SupportedFormat(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
expected := "docx"
|
expected := "docx"
|
||||||
result := exporter.SupportedFormat()
|
result := exporter.SupportedFormat()
|
||||||
@@ -45,7 +45,7 @@ func TestDocxExporter_SupportedFormat(t *testing.T) {
|
|||||||
// TestDocxExporter_Export tests the Export method.
|
// TestDocxExporter_Export tests the Export method.
|
||||||
func TestDocxExporter_Export(t *testing.T) {
|
func TestDocxExporter_Export(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create test course
|
// Create test course
|
||||||
testCourse := createTestCourseForDocx()
|
testCourse := createTestCourseForDocx()
|
||||||
@@ -79,7 +79,7 @@ func TestDocxExporter_Export(t *testing.T) {
|
|||||||
// TestDocxExporter_Export_AddDocxExtension tests that the .docx extension is added automatically.
|
// TestDocxExporter_Export_AddDocxExtension tests that the .docx extension is added automatically.
|
||||||
func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
|
func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
testCourse := createTestCourseForDocx()
|
testCourse := createTestCourseForDocx()
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
|
|||||||
// TestDocxExporter_Export_InvalidPath tests export with invalid output path.
|
// TestDocxExporter_Export_InvalidPath tests export with invalid output path.
|
||||||
func TestDocxExporter_Export_InvalidPath(t *testing.T) {
|
func TestDocxExporter_Export_InvalidPath(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
testCourse := createTestCourseForDocx()
|
testCourse := createTestCourseForDocx()
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ func TestDocxExporter_Export_InvalidPath(t *testing.T) {
|
|||||||
// TestDocxExporter_ExportLesson tests the exportLesson method indirectly through Export.
|
// TestDocxExporter_ExportLesson tests the exportLesson method indirectly through Export.
|
||||||
func TestDocxExporter_ExportLesson(t *testing.T) {
|
func TestDocxExporter_ExportLesson(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create course with specific lesson content
|
// Create course with specific lesson content
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
@@ -165,7 +165,7 @@ func TestDocxExporter_ExportLesson(t *testing.T) {
|
|||||||
// TestDocxExporter_ExportItem tests the exportItem method indirectly through Export.
|
// TestDocxExporter_ExportItem tests the exportItem method indirectly through Export.
|
||||||
func TestDocxExporter_ExportItem(t *testing.T) {
|
func TestDocxExporter_ExportItem(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create course with different item types
|
// Create course with different item types
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
@@ -231,7 +231,7 @@ func TestDocxExporter_ExportItem(t *testing.T) {
|
|||||||
// TestDocxExporter_ExportSubItem tests the exportSubItem method indirectly through Export.
|
// TestDocxExporter_ExportSubItem tests the exportSubItem method indirectly through Export.
|
||||||
func TestDocxExporter_ExportSubItem(t *testing.T) {
|
func TestDocxExporter_ExportSubItem(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create course with sub-item containing all possible fields
|
// Create course with sub-item containing all possible fields
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
@@ -284,7 +284,7 @@ func TestDocxExporter_ExportSubItem(t *testing.T) {
|
|||||||
// TestDocxExporter_ComplexCourse tests export of a complex course structure.
|
// TestDocxExporter_ComplexCourse tests export of a complex course structure.
|
||||||
func TestDocxExporter_ComplexCourse(t *testing.T) {
|
func TestDocxExporter_ComplexCourse(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create complex test course
|
// Create complex test course
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
@@ -421,7 +421,7 @@ func TestDocxExporter_ComplexCourse(t *testing.T) {
|
|||||||
// TestDocxExporter_EmptyCourse tests export of an empty course.
|
// TestDocxExporter_EmptyCourse tests export of an empty course.
|
||||||
func TestDocxExporter_EmptyCourse(t *testing.T) {
|
func TestDocxExporter_EmptyCourse(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create minimal course
|
// Create minimal course
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
@@ -450,7 +450,7 @@ func TestDocxExporter_EmptyCourse(t *testing.T) {
|
|||||||
// TestDocxExporter_HTMLCleaning tests that HTML content is properly cleaned.
|
// TestDocxExporter_HTMLCleaning tests that HTML content is properly cleaned.
|
||||||
func TestDocxExporter_HTMLCleaning(t *testing.T) {
|
func TestDocxExporter_HTMLCleaning(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create course with HTML content that needs cleaning
|
// Create course with HTML content that needs cleaning
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
@@ -498,7 +498,7 @@ func TestDocxExporter_HTMLCleaning(t *testing.T) {
|
|||||||
// TestDocxExporter_ExistingDocxExtension tests that existing .docx extension is preserved.
|
// TestDocxExporter_ExistingDocxExtension tests that existing .docx extension is preserved.
|
||||||
func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
|
func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
testCourse := createTestCourseForDocx()
|
testCourse := createTestCourseForDocx()
|
||||||
|
|
||||||
@@ -526,7 +526,7 @@ func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
|
|||||||
// TestDocxExporter_CaseInsensitiveExtension tests that extension checking is case-insensitive.
|
// TestDocxExporter_CaseInsensitiveExtension tests that extension checking is case-insensitive.
|
||||||
func TestDocxExporter_CaseInsensitiveExtension(t *testing.T) {
|
func TestDocxExporter_CaseInsensitiveExtension(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
testCourse := createTestCourseForDocx()
|
testCourse := createTestCourseForDocx()
|
||||||
|
|
||||||
@@ -599,7 +599,7 @@ func createTestCourseForDocx() *models.Course {
|
|||||||
// BenchmarkDocxExporter_Export benchmarks the Export method.
|
// BenchmarkDocxExporter_Export benchmarks the Export method.
|
||||||
func BenchmarkDocxExporter_Export(b *testing.B) {
|
func BenchmarkDocxExporter_Export(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
course := createTestCourseForDocx()
|
course := createTestCourseForDocx()
|
||||||
|
|
||||||
// Create temporary directory
|
// Create temporary directory
|
||||||
@@ -618,7 +618,7 @@ func BenchmarkDocxExporter_Export(b *testing.B) {
|
|||||||
// BenchmarkDocxExporter_ComplexCourse benchmarks export of a complex course.
|
// BenchmarkDocxExporter_ComplexCourse benchmarks export of a complex course.
|
||||||
func BenchmarkDocxExporter_ComplexCourse(b *testing.B) {
|
func BenchmarkDocxExporter_ComplexCourse(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewDocxExporter(htmlCleaner)
|
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create complex course for benchmarking
|
// Create complex course for benchmarking
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
|
|||||||
@@ -4,16 +4,33 @@ package exporters_test
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/kjanat/articulate-parser/internal/exporters"
|
"github.com/kjanat/articulate-parser/internal/exporters"
|
||||||
"github.com/kjanat/articulate-parser/internal/models"
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
"github.com/kjanat/articulate-parser/internal/services"
|
"github.com/kjanat/articulate-parser/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// exportToTemp runs export against a path in a fresh temp dir and removes the
|
||||||
|
// dir afterwards. Example functions can't take *testing.T, so t.TempDir is
|
||||||
|
// unavailable here.
|
||||||
|
func exportToTemp(name string, export func(path string) error) {
|
||||||
|
dir, err := os.MkdirTemp("", "exporters-example-*")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
err = export(filepath.Join(dir, name))
|
||||||
|
_ = os.RemoveAll(dir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ExampleNewFactory demonstrates creating an exporter factory.
|
// ExampleNewFactory demonstrates creating an exporter factory.
|
||||||
func ExampleNewFactory() {
|
func ExampleNewFactory() {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := exporters.NewFactory(htmlCleaner)
|
factory := exporters.NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
// Get supported formats
|
// Get supported formats
|
||||||
formats := factory.SupportedFormats()
|
formats := factory.SupportedFormats()
|
||||||
@@ -24,7 +41,7 @@ func ExampleNewFactory() {
|
|||||||
// ExampleFactory_CreateExporter demonstrates creating exporters.
|
// ExampleFactory_CreateExporter demonstrates creating exporters.
|
||||||
func ExampleFactory_CreateExporter() {
|
func ExampleFactory_CreateExporter() {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := exporters.NewFactory(htmlCleaner)
|
factory := exporters.NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create a markdown exporter
|
// Create a markdown exporter
|
||||||
exporter, err := factory.CreateExporter("markdown")
|
exporter, err := factory.CreateExporter("markdown")
|
||||||
@@ -39,7 +56,7 @@ func ExampleFactory_CreateExporter() {
|
|||||||
// ExampleFactory_CreateExporter_caseInsensitive demonstrates case-insensitive format names.
|
// ExampleFactory_CreateExporter_caseInsensitive demonstrates case-insensitive format names.
|
||||||
func ExampleFactory_CreateExporter_caseInsensitive() {
|
func ExampleFactory_CreateExporter_caseInsensitive() {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := exporters.NewFactory(htmlCleaner)
|
factory := exporters.NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
// All these work (case-insensitive)
|
// All these work (case-insensitive)
|
||||||
formats := []string{"MARKDOWN", "Markdown", "markdown", "MD"}
|
formats := []string{"MARKDOWN", "Markdown", "markdown", "MD"}
|
||||||
@@ -58,7 +75,7 @@ func ExampleFactory_CreateExporter_caseInsensitive() {
|
|||||||
// ExampleMarkdownExporter_Export demonstrates exporting to Markdown.
|
// ExampleMarkdownExporter_Export demonstrates exporting to Markdown.
|
||||||
func ExampleMarkdownExporter_Export() {
|
func ExampleMarkdownExporter_Export() {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := exporters.NewMarkdownExporter(htmlCleaner)
|
exporter := exporters.NewMarkdownExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
ShareID: "example-id",
|
ShareID: "example-id",
|
||||||
@@ -69,10 +86,9 @@ func ExampleMarkdownExporter_Export() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export to markdown file
|
// Export to markdown file
|
||||||
err := exporter.Export(course, "output.md")
|
exportToTemp("output.md", func(path string) error {
|
||||||
if err != nil {
|
return exporter.Export(course, path)
|
||||||
log.Fatal(err)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Export complete")
|
fmt.Println("Export complete")
|
||||||
// Output: Export complete
|
// Output: Export complete
|
||||||
@@ -81,7 +97,7 @@ func ExampleMarkdownExporter_Export() {
|
|||||||
// ExampleDocxExporter_Export demonstrates exporting to DOCX.
|
// ExampleDocxExporter_Export demonstrates exporting to DOCX.
|
||||||
func ExampleDocxExporter_Export() {
|
func ExampleDocxExporter_Export() {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := exporters.NewDocxExporter(htmlCleaner)
|
exporter := exporters.NewDocxExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
ShareID: "example-id",
|
ShareID: "example-id",
|
||||||
@@ -91,11 +107,39 @@ func ExampleDocxExporter_Export() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export to Word document
|
// Export to Word document
|
||||||
err := exporter.Export(course, "output.docx")
|
exportToTemp("output.docx", func(path string) error {
|
||||||
if err != nil {
|
return exporter.Export(course, path)
|
||||||
log.Fatal(err)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("DOCX export complete")
|
fmt.Println("DOCX export complete")
|
||||||
// Output: DOCX export complete
|
// Output: DOCX export complete
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExampleHTMLExporter_Export demonstrates exporting to HTML.
|
||||||
|
func ExampleHTMLExporter_Export() {
|
||||||
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
|
exporter := exporters.NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
|
course := &models.Course{
|
||||||
|
ShareID: "example-id",
|
||||||
|
Course: models.CourseInfo{
|
||||||
|
Title: "Example Course",
|
||||||
|
Description: "<p>A sample course</p>",
|
||||||
|
Lessons: []models.Lesson{
|
||||||
|
{
|
||||||
|
ID: "lesson-1",
|
||||||
|
Title: "Introduction",
|
||||||
|
Type: "lesson",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export to HTML file
|
||||||
|
exportToTemp("output.html", func(path string) error {
|
||||||
|
return exporter.Export(course, path)
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Println("HTML export complete")
|
||||||
|
// Output: HTML export complete
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/apperrors"
|
||||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||||
"github.com/kjanat/articulate-parser/internal/services"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Format constants for supported export formats.
|
// Format constants for supported export formats.
|
||||||
@@ -13,27 +13,36 @@ const (
|
|||||||
FormatMarkdown = "markdown"
|
FormatMarkdown = "markdown"
|
||||||
FormatDocx = "docx"
|
FormatDocx = "docx"
|
||||||
FormatHTML = "html"
|
FormatHTML = "html"
|
||||||
|
|
||||||
|
// Format aliases accepted by CreateExporter.
|
||||||
|
formatAliasMarkdown = "md"
|
||||||
|
formatAliasDocx = "word"
|
||||||
|
formatAliasHTML = "htm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Factory implements the ExporterFactory interface.
|
// Factory implements the ExporterFactory interface.
|
||||||
// It creates appropriate exporter instances based on the requested format.
|
// It creates appropriate exporter instances based on the requested format.
|
||||||
type Factory struct {
|
type Factory struct {
|
||||||
// htmlCleaner is used by exporters to convert HTML content to plain text
|
// htmlCleaner is used by exporters to convert HTML content to plain text
|
||||||
htmlCleaner *services.HTMLCleaner
|
htmlCleaner interfaces.HTMLCleaner
|
||||||
|
// logger is used by exporters for logging
|
||||||
|
logger interfaces.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFactory creates a new exporter factory.
|
// NewFactory creates a new exporter factory.
|
||||||
// It takes an HTMLCleaner instance that will be passed to the exporters
|
// It takes an HTMLCleaner instance and a logger that will be passed to the exporters
|
||||||
// created by this factory.
|
// created by this factory.
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - htmlCleaner: Service for cleaning HTML content in course data
|
// - htmlCleaner: Service for cleaning HTML content in course data
|
||||||
|
// - logger: Logger instance for exporter logging
|
||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - An implementation of the ExporterFactory interface
|
// - An implementation of the ExporterFactory interface
|
||||||
func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory {
|
func NewFactory(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.ExporterFactory {
|
||||||
return &Factory{
|
return &Factory{
|
||||||
htmlCleaner: htmlCleaner,
|
htmlCleaner: htmlCleaner,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,19 +50,23 @@ func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory {
|
|||||||
// Format strings are case-insensitive (e.g., "markdown", "DOCX").
|
// Format strings are case-insensitive (e.g., "markdown", "DOCX").
|
||||||
func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
|
func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
|
||||||
switch strings.ToLower(format) {
|
switch strings.ToLower(format) {
|
||||||
case FormatMarkdown, "md":
|
case FormatMarkdown, formatAliasMarkdown:
|
||||||
return NewMarkdownExporter(f.htmlCleaner), nil
|
return NewMarkdownExporter(f.htmlCleaner, f.logger), nil
|
||||||
case FormatDocx, "word":
|
case FormatDocx, formatAliasDocx:
|
||||||
return NewDocxExporter(f.htmlCleaner), nil
|
return NewDocxExporter(f.htmlCleaner, f.logger), nil
|
||||||
case FormatHTML, "htm":
|
case FormatHTML, formatAliasHTML:
|
||||||
return NewHTMLExporter(f.htmlCleaner), nil
|
return NewHTMLExporter(f.htmlCleaner, f.logger), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported export format: %s", format)
|
return nil, fmt.Errorf("%w: %s", apperrors.ErrUnsupportedFormat, format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportedFormats returns a list of all supported export formats,
|
// SupportedFormats returns a list of all supported export formats,
|
||||||
// including both primary format names and their aliases.
|
// including both primary format names and their aliases.
|
||||||
func (f *Factory) SupportedFormats() []string {
|
func (f *Factory) SupportedFormats() []string {
|
||||||
return []string{FormatMarkdown, "md", FormatDocx, "word", FormatHTML, "htm"}
|
return []string{
|
||||||
|
FormatMarkdown, formatAliasMarkdown,
|
||||||
|
FormatDocx, formatAliasDocx,
|
||||||
|
FormatHTML, formatAliasHTML,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
// TestNewFactory tests the NewFactory constructor.
|
// TestNewFactory tests the NewFactory constructor.
|
||||||
func TestNewFactory(t *testing.T) {
|
func TestNewFactory(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
if factory == nil {
|
if factory == nil {
|
||||||
t.Fatal("NewFactory() returned nil")
|
t.Fatal("NewFactory() returned nil")
|
||||||
@@ -32,7 +32,7 @@ func TestNewFactory(t *testing.T) {
|
|||||||
// TestFactory_CreateExporter tests the CreateExporter method for all supported formats.
|
// TestFactory_CreateExporter tests the CreateExporter method for all supported formats.
|
||||||
func TestFactory_CreateExporter(t *testing.T) {
|
func TestFactory_CreateExporter(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -135,7 +135,7 @@ func TestFactory_CreateExporter(t *testing.T) {
|
|||||||
// TestFactory_CreateExporter_CaseInsensitive tests that format strings are case-insensitive.
|
// TestFactory_CreateExporter_CaseInsensitive tests that format strings are case-insensitive.
|
||||||
func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
|
func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
format string
|
format string
|
||||||
@@ -182,7 +182,7 @@ func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
|
|||||||
// TestFactory_CreateExporter_ErrorMessages tests error messages for unsupported formats.
|
// TestFactory_CreateExporter_ErrorMessages tests error messages for unsupported formats.
|
||||||
func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
|
func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
testCases := []string{
|
testCases := []string{
|
||||||
"pdf",
|
"pdf",
|
||||||
@@ -222,7 +222,7 @@ func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
|
|||||||
// TestFactory_SupportedFormats tests the SupportedFormats method.
|
// TestFactory_SupportedFormats tests the SupportedFormats method.
|
||||||
func TestFactory_SupportedFormats(t *testing.T) {
|
func TestFactory_SupportedFormats(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
formats := factory.SupportedFormats()
|
formats := factory.SupportedFormats()
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ func TestFactory_SupportedFormats(t *testing.T) {
|
|||||||
// TestFactory_SupportedFormats_Immutable tests that the returned slice is safe to modify.
|
// TestFactory_SupportedFormats_Immutable tests that the returned slice is safe to modify.
|
||||||
func TestFactory_SupportedFormats_Immutable(t *testing.T) {
|
func TestFactory_SupportedFormats_Immutable(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
// Get formats twice
|
// Get formats twice
|
||||||
formats1 := factory.SupportedFormats()
|
formats1 := factory.SupportedFormats()
|
||||||
@@ -281,7 +281,7 @@ func TestFactory_SupportedFormats_Immutable(t *testing.T) {
|
|||||||
// TestFactory_ExporterTypes tests that created exporters are of correct types.
|
// TestFactory_ExporterTypes tests that created exporters are of correct types.
|
||||||
func TestFactory_ExporterTypes(t *testing.T) {
|
func TestFactory_ExporterTypes(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
// Test markdown exporter
|
// Test markdown exporter
|
||||||
markdownExporter, err := factory.CreateExporter("markdown")
|
markdownExporter, err := factory.CreateExporter("markdown")
|
||||||
@@ -307,7 +307,7 @@ func TestFactory_ExporterTypes(t *testing.T) {
|
|||||||
// TestFactory_HTMLCleanerPropagation tests that HTMLCleaner is properly passed to exporters.
|
// TestFactory_HTMLCleanerPropagation tests that HTMLCleaner is properly passed to exporters.
|
||||||
func TestFactory_HTMLCleanerPropagation(t *testing.T) {
|
func TestFactory_HTMLCleanerPropagation(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
// Test with markdown exporter
|
// Test with markdown exporter
|
||||||
markdownExporter, err := factory.CreateExporter("markdown")
|
markdownExporter, err := factory.CreateExporter("markdown")
|
||||||
@@ -358,7 +358,7 @@ func TestFactory_HTMLCleanerPropagation(t *testing.T) {
|
|||||||
// TestFactory_MultipleExporterCreation tests creating multiple exporters of same type.
|
// TestFactory_MultipleExporterCreation tests creating multiple exporters of same type.
|
||||||
func TestFactory_MultipleExporterCreation(t *testing.T) {
|
func TestFactory_MultipleExporterCreation(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create multiple markdown exporters
|
// Create multiple markdown exporters
|
||||||
exporter1, err := factory.CreateExporter("markdown")
|
exporter1, err := factory.CreateExporter("markdown")
|
||||||
@@ -395,7 +395,7 @@ func TestFactory_WithNilHTMLCleaner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
factory := NewFactory(nil)
|
factory := NewFactory(nil, nil)
|
||||||
|
|
||||||
if factory == nil {
|
if factory == nil {
|
||||||
t.Fatal("NewFactory(nil) returned nil")
|
t.Fatal("NewFactory(nil) returned nil")
|
||||||
@@ -412,7 +412,7 @@ func TestFactory_WithNilHTMLCleaner(t *testing.T) {
|
|||||||
// TestFactory_FormatNormalization tests that format strings are properly normalized.
|
// TestFactory_FormatNormalization tests that format strings are properly normalized.
|
||||||
func TestFactory_FormatNormalization(t *testing.T) {
|
func TestFactory_FormatNormalization(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
// Test formats with extra whitespace
|
// Test formats with extra whitespace
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
@@ -445,7 +445,7 @@ func TestFactory_FormatNormalization(t *testing.T) {
|
|||||||
// BenchmarkFactory_CreateExporter benchmarks the CreateExporter method.
|
// BenchmarkFactory_CreateExporter benchmarks the CreateExporter method.
|
||||||
func BenchmarkFactory_CreateExporter(b *testing.B) {
|
func BenchmarkFactory_CreateExporter(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
for b.Loop() {
|
for b.Loop() {
|
||||||
_, _ = factory.CreateExporter("markdown")
|
_, _ = factory.CreateExporter("markdown")
|
||||||
@@ -455,7 +455,7 @@ func BenchmarkFactory_CreateExporter(b *testing.B) {
|
|||||||
// BenchmarkFactory_CreateExporter_Docx benchmarks creating DOCX exporters.
|
// BenchmarkFactory_CreateExporter_Docx benchmarks creating DOCX exporters.
|
||||||
func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
|
func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
for b.Loop() {
|
for b.Loop() {
|
||||||
_, _ = factory.CreateExporter("docx")
|
_, _ = factory.CreateExporter("docx")
|
||||||
@@ -465,7 +465,7 @@ func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
|
|||||||
// BenchmarkFactory_SupportedFormats benchmarks the SupportedFormats method.
|
// BenchmarkFactory_SupportedFormats benchmarks the SupportedFormats method.
|
||||||
func BenchmarkFactory_SupportedFormats(b *testing.B) {
|
func BenchmarkFactory_SupportedFormats(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
factory := NewFactory(htmlCleaner)
|
factory := NewFactory(htmlCleaner, nil)
|
||||||
|
|
||||||
for b.Loop() {
|
for b.Loop() {
|
||||||
_ = factory.SupportedFormats()
|
_ = factory.SupportedFormats()
|
||||||
|
|||||||
+19
-10
@@ -9,33 +9,33 @@ import (
|
|||||||
|
|
||||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||||
"github.com/kjanat/articulate-parser/internal/models"
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
"github.com/kjanat/articulate-parser/internal/services"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed html_styles.css
|
//go:embed html_styles.css
|
||||||
var defaultCSS string
|
var defaultCSS string
|
||||||
|
|
||||||
//go:embed html_template.html
|
//go:embed html_template.gohtml
|
||||||
var htmlTemplate string
|
var htmlTemplate string
|
||||||
|
|
||||||
// HTMLExporter implements the Exporter interface for HTML format.
|
// HTMLExporter implements the Exporter interface for HTML format.
|
||||||
// It converts Articulate Rise course data into a structured HTML document using templates.
|
// It converts Articulate Rise course data into a structured HTML document using templates.
|
||||||
type HTMLExporter struct {
|
type HTMLExporter struct {
|
||||||
// htmlCleaner is used to convert HTML content to plain text when needed
|
baseExporter
|
||||||
htmlCleaner *services.HTMLCleaner
|
// tmpl holds the parsed HTML template.
|
||||||
// tmpl holds the parsed HTML template
|
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTMLExporter creates a new HTMLExporter instance.
|
// NewHTMLExporter creates a new HTMLExporter instance.
|
||||||
// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed.
|
// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed
|
||||||
|
// and a logger for logging.
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - htmlCleaner: Service for cleaning HTML content in course data
|
// - htmlCleaner: Service for cleaning HTML content in course data
|
||||||
|
// - logger: Logger instance for warnings and errors
|
||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - An implementation of the Exporter interface for HTML format
|
// - An implementation of the Exporter interface for HTML format
|
||||||
func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
|
func NewHTMLExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
|
||||||
// Parse the template with custom functions
|
// Parse the template with custom functions
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"safeHTML": func(s string) template.HTML {
|
"safeHTML": func(s string) template.HTML {
|
||||||
@@ -49,8 +49,8 @@ func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
|
|||||||
tmpl := template.Must(template.New("html").Funcs(funcMap).Parse(htmlTemplate))
|
tmpl := template.Must(template.New("html").Funcs(funcMap).Parse(htmlTemplate))
|
||||||
|
|
||||||
return &HTMLExporter{
|
return &HTMLExporter{
|
||||||
htmlCleaner: htmlCleaner,
|
baseExporter: newBaseExporter(htmlCleaner, logger),
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,16 @@ func (e *HTMLExporter) Export(course *models.Course, outputPath string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create file: %w", err)
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer func() {
|
||||||
|
// Close errors are logged but not fatal since the content has already been written.
|
||||||
|
// The file must be closed to flush buffers, but a close error doesn't invalidate
|
||||||
|
// the data already written to disk.
|
||||||
|
if closeErr := f.Close(); closeErr != nil {
|
||||||
|
// Note: In production, this should log via a logger passed to the exporter.
|
||||||
|
// For now, we silently ignore close errors as they're non-fatal.
|
||||||
|
_ = closeErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return e.WriteHTML(f, course)
|
return e.WriteHTML(f, course)
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-107
@@ -1,173 +1,175 @@
|
|||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
font-family:
|
||||||
line-height: 1.6;
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||||
color: #333;
|
Cantarell, sans-serif;
|
||||||
max-width: 800px;
|
line-height: 1.6;
|
||||||
margin: 0 auto;
|
color: #333;
|
||||||
padding: 20px;
|
max-width: 800px;
|
||||||
background-color: #f9f9f9;
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
header h1 {
|
header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
.course-description {
|
.course-description {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.course-info {
|
.course-info {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
.course-info h2 {
|
.course-info h2 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #4a5568;
|
color: #4a5568;
|
||||||
border-bottom: 2px solid #e2e8f0;
|
border-bottom: 2px solid #e2e8f0;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
.course-info ul {
|
.course-info ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.course-info li {
|
.course-info li {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #f7fafc;
|
background: #f7fafc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.course-section {
|
.course-section {
|
||||||
background: #4299e1;
|
background: #4299e1;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
.course-section h2 {
|
.course-section h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
.lesson {
|
.lesson {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
border-left: 4px solid #4299e1;
|
border-left: 4px solid #4299e1;
|
||||||
}
|
}
|
||||||
.lesson h3 {
|
.lesson h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #2d3748;
|
color: #2d3748;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
.lesson-description {
|
.lesson-description {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #f7fafc;
|
background: #f7fafc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border-left: 3px solid #4299e1;
|
border-left: 3px solid #4299e1;
|
||||||
}
|
}
|
||||||
.item {
|
.item {
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
.item h4 {
|
.item h4 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #4a5568;
|
color: #4a5568;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
.text-item {
|
.text-item {
|
||||||
background: #f0fff4;
|
background: #f0fff4;
|
||||||
border-left: 3px solid #48bb78;
|
border-left: 3px solid #48bb78;
|
||||||
}
|
}
|
||||||
.list-item {
|
.list-item {
|
||||||
background: #fffaf0;
|
background: #fffaf0;
|
||||||
border-left: 3px solid #ed8936;
|
border-left: 3px solid #ed8936;
|
||||||
}
|
}
|
||||||
.knowledge-check {
|
.knowledge-check {
|
||||||
background: #e6fffa;
|
background: #e6fffa;
|
||||||
border-left: 3px solid #38b2ac;
|
border-left: 3px solid #38b2ac;
|
||||||
}
|
}
|
||||||
.multimedia-item {
|
.multimedia-item {
|
||||||
background: #faf5ff;
|
background: #faf5ff;
|
||||||
border-left: 3px solid #9f7aea;
|
border-left: 3px solid #9f7aea;
|
||||||
}
|
}
|
||||||
.interactive-item {
|
.interactive-item {
|
||||||
background: #fff5f5;
|
background: #fff5f5;
|
||||||
border-left: 3px solid #f56565;
|
border-left: 3px solid #f56565;
|
||||||
}
|
}
|
||||||
.unknown-item {
|
.unknown-item {
|
||||||
background: #f7fafc;
|
background: #f7fafc;
|
||||||
border-left: 3px solid #a0aec0;
|
border-left: 3px solid #a0aec0;
|
||||||
}
|
}
|
||||||
.answers {
|
.answers {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
.answers h5 {
|
.answers h5 {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
color: #4a5568;
|
color: #4a5568;
|
||||||
}
|
}
|
||||||
.answers ol {
|
.answers ol {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
}
|
}
|
||||||
.answers li {
|
.answers li {
|
||||||
margin: 0.3rem 0;
|
margin: 0.3rem 0;
|
||||||
padding: 0.3rem;
|
padding: 0.3rem;
|
||||||
}
|
}
|
||||||
.correct-answer {
|
.correct-answer {
|
||||||
background: #c6f6d5;
|
background: #c6f6d5;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.correct-answer::after {
|
.correct-answer::after {
|
||||||
content: " ✓";
|
content: " ✓";
|
||||||
color: #38a169;
|
color: #38a169;
|
||||||
}
|
}
|
||||||
.feedback {
|
.feedback {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #edf2f7;
|
background: #edf2f7;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border-left: 3px solid #4299e1;
|
border-left: 3px solid #4299e1;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.media-info {
|
.media-info {
|
||||||
background: #edf2f7;
|
background: #edf2f7;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
.media-info strong {
|
.media-info strong {
|
||||||
color: #4a5568;
|
color: #4a5568;
|
||||||
}
|
}
|
||||||
hr {
|
hr {
|
||||||
border: none;
|
border: none;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: linear-gradient(to right, #667eea, #764ba2);
|
background: linear-gradient(to right, #667eea, #764ba2);
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
}
|
}
|
||||||
ul {
|
ul {
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
}
|
}
|
||||||
li {
|
li {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,8 @@ import (
|
|||||||
"golang.org/x/text/cases"
|
"golang.org/x/text/cases"
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||||
"github.com/kjanat/articulate-parser/internal/models"
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
"github.com/kjanat/articulate-parser/internal/services"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Item type constants.
|
|
||||||
const (
|
|
||||||
itemTypeText = "text"
|
|
||||||
itemTypeList = "list"
|
|
||||||
itemTypeKnowledgeCheck = "knowledgecheck"
|
|
||||||
itemTypeMultimedia = "multimedia"
|
|
||||||
itemTypeImage = "image"
|
|
||||||
itemTypeInteractive = "interactive"
|
|
||||||
itemTypeDivider = "divider"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// templateData represents the data structure passed to the HTML template.
|
// templateData represents the data structure passed to the HTML template.
|
||||||
@@ -58,7 +47,7 @@ type templateSubItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// prepareTemplateData converts a Course model into template-friendly data.
|
// prepareTemplateData converts a Course model into template-friendly data.
|
||||||
func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleaner) *templateData {
|
func prepareTemplateData(course *models.Course, htmlCleaner interfaces.HTMLCleaner) *templateData {
|
||||||
data := &templateData{
|
data := &templateData{
|
||||||
Course: course.Course,
|
Course: course.Course,
|
||||||
ShareID: course.ShareID,
|
ShareID: course.ShareID,
|
||||||
@@ -74,7 +63,7 @@ func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleane
|
|||||||
Description: lesson.Description,
|
Description: lesson.Description,
|
||||||
}
|
}
|
||||||
|
|
||||||
if lesson.Type != "section" {
|
if lesson.Type != models.LessonTypeSection {
|
||||||
lessonCounter++
|
lessonCounter++
|
||||||
section.Number = lessonCounter
|
section.Number = lessonCounter
|
||||||
section.Items = prepareItems(lesson.Items, htmlCleaner)
|
section.Items = prepareItems(lesson.Items, htmlCleaner)
|
||||||
@@ -87,7 +76,7 @@ func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleane
|
|||||||
}
|
}
|
||||||
|
|
||||||
// prepareItems converts model Items to template Items.
|
// prepareItems converts model Items to template Items.
|
||||||
func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []templateItem {
|
func prepareItems(items []models.Item, htmlCleaner interfaces.HTMLCleaner) []templateItem {
|
||||||
result := make([]templateItem, 0, len(items))
|
result := make([]templateItem, 0, len(items))
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
@@ -97,9 +86,9 @@ func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []temp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set type title for unknown items
|
// Set type title for unknown items
|
||||||
if tItem.Type != itemTypeText && tItem.Type != itemTypeList && tItem.Type != itemTypeKnowledgeCheck &&
|
if tItem.Type != models.ItemTypeText && tItem.Type != models.ItemTypeList && tItem.Type != models.ItemTypeKnowledgeCheck &&
|
||||||
tItem.Type != itemTypeMultimedia && tItem.Type != itemTypeImage && tItem.Type != itemTypeInteractive &&
|
tItem.Type != models.ItemTypeMultimedia && tItem.Type != models.ItemTypeImage && tItem.Type != models.ItemTypeInteractive &&
|
||||||
tItem.Type != itemTypeDivider {
|
tItem.Type != models.ItemTypeDivider {
|
||||||
caser := cases.Title(language.English)
|
caser := cases.Title(language.English)
|
||||||
tItem.TypeTitle = caser.String(item.Type)
|
tItem.TypeTitle = caser.String(item.Type)
|
||||||
}
|
}
|
||||||
@@ -117,7 +106,7 @@ func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []temp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clean HTML for list items
|
// Clean HTML for list items
|
||||||
if tItem.Type == itemTypeList && subItem.Paragraph != "" {
|
if tItem.Type == models.ItemTypeList && subItem.Paragraph != "" {
|
||||||
tSubItem.CleanText = htmlCleaner.CleanHTML(subItem.Paragraph)
|
tSubItem.CleanText = htmlCleaner.CleanHTML(subItem.Paragraph)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
// TestNewHTMLExporter tests the NewHTMLExporter constructor.
|
// TestNewHTMLExporter tests the NewHTMLExporter constructor.
|
||||||
func TestNewHTMLExporter(t *testing.T) {
|
func TestNewHTMLExporter(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewHTMLExporter(htmlCleaner)
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
if exporter == nil {
|
if exporter == nil {
|
||||||
t.Fatal("NewHTMLExporter() returned nil")
|
t.Fatal("NewHTMLExporter() returned nil")
|
||||||
@@ -37,7 +37,7 @@ func TestNewHTMLExporter(t *testing.T) {
|
|||||||
// TestHTMLExporter_SupportedFormat tests the SupportedFormat method.
|
// TestHTMLExporter_SupportedFormat tests the SupportedFormat method.
|
||||||
func TestHTMLExporter_SupportedFormat(t *testing.T) {
|
func TestHTMLExporter_SupportedFormat(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewHTMLExporter(htmlCleaner)
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
expected := "html"
|
expected := "html"
|
||||||
result := exporter.SupportedFormat()
|
result := exporter.SupportedFormat()
|
||||||
@@ -50,7 +50,7 @@ func TestHTMLExporter_SupportedFormat(t *testing.T) {
|
|||||||
// TestHTMLExporter_Export tests the Export method.
|
// TestHTMLExporter_Export tests the Export method.
|
||||||
func TestHTMLExporter_Export(t *testing.T) {
|
func TestHTMLExporter_Export(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewHTMLExporter(htmlCleaner)
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create test course
|
// Create test course
|
||||||
testCourse := createTestCourseForHTML()
|
testCourse := createTestCourseForHTML()
|
||||||
@@ -129,7 +129,7 @@ func TestHTMLExporter_Export(t *testing.T) {
|
|||||||
// TestHTMLExporter_Export_InvalidPath tests export with invalid output path.
|
// TestHTMLExporter_Export_InvalidPath tests export with invalid output path.
|
||||||
func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
|
func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewHTMLExporter(htmlCleaner)
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
testCourse := createTestCourseForHTML()
|
testCourse := createTestCourseForHTML()
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
|
|||||||
// TestHTMLExporter_ComplexCourse tests export of a course with complex content.
|
// TestHTMLExporter_ComplexCourse tests export of a course with complex content.
|
||||||
func TestHTMLExporter_ComplexCourse(t *testing.T) {
|
func TestHTMLExporter_ComplexCourse(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewHTMLExporter(htmlCleaner)
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create complex test course
|
// Create complex test course
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
@@ -276,7 +276,7 @@ func TestHTMLExporter_ComplexCourse(t *testing.T) {
|
|||||||
// TestHTMLExporter_EmptyCourse tests export of an empty course.
|
// TestHTMLExporter_EmptyCourse tests export of an empty course.
|
||||||
func TestHTMLExporter_EmptyCourse(t *testing.T) {
|
func TestHTMLExporter_EmptyCourse(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewHTMLExporter(htmlCleaner)
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create minimal course
|
// Create minimal course
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
@@ -324,7 +324,7 @@ func TestHTMLExporter_EmptyCourse(t *testing.T) {
|
|||||||
// TestHTMLExporter_HTMLCleaning tests that HTML content is properly handled.
|
// TestHTMLExporter_HTMLCleaning tests that HTML content is properly handled.
|
||||||
func TestHTMLExporter_HTMLCleaning(t *testing.T) {
|
func TestHTMLExporter_HTMLCleaning(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewHTMLExporter(htmlCleaner)
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create course with HTML content that needs cleaning in some places
|
// Create course with HTML content that needs cleaning in some places
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
@@ -392,6 +392,227 @@ func TestHTMLExporter_HTMLCleaning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHTMLExporter_Export_ErrorPaths tests various error conditions for the HTML exporter.
|
||||||
|
func TestHTMLExporter_Export_ErrorPaths(t *testing.T) {
|
||||||
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
course *models.Course
|
||||||
|
outputPath string
|
||||||
|
wantErr bool
|
||||||
|
errContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "non-existent directory",
|
||||||
|
course: createTestCourseForHTML(),
|
||||||
|
outputPath: "/nonexistent/deeply/nested/path/output.html",
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "failed to create file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty output path",
|
||||||
|
course: createTestCourseForHTML(),
|
||||||
|
outputPath: "",
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "failed to create file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output path is directory",
|
||||||
|
course: createTestCourseForHTML(),
|
||||||
|
outputPath: t.TempDir(), // directory, not a file
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "failed to create file",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := exporter.Export(tt.course, tt.outputPath)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Export() expected error containing %q, got nil", tt.errContains)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||||
|
t.Errorf("Export() error = %v, want error containing %q", err, tt.errContains)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
t.Errorf("Export() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTMLExporter_WriteHTML_ErrorPaths tests WriteHTML error conditions.
|
||||||
|
func TestHTMLExporter_WriteHTML_ErrorPaths(t *testing.T) {
|
||||||
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
htmlExporter := exporter.(*HTMLExporter)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
course *models.Course
|
||||||
|
writer *failingWriter
|
||||||
|
wantErr bool
|
||||||
|
errContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "writer fails immediately",
|
||||||
|
course: createTestCourseForHTML(),
|
||||||
|
writer: &failingWriter{failAfter: 0},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "failed to execute template",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "writer fails mid-write",
|
||||||
|
course: createTestCourseForHTML(),
|
||||||
|
writer: &failingWriter{failAfter: 100},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "failed to execute template",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := htmlExporter.WriteHTML(tt.writer, tt.course)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("WriteHTML() expected error containing %q, got nil", tt.errContains)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||||
|
t.Errorf("WriteHTML() error = %v, want error containing %q", err, tt.errContains)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
t.Errorf("WriteHTML() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTMLExporter_Export_NilCourse verifies that exporting a nil course panics.
|
||||||
|
// This documents the current behavior - nil course is not a supported input.
|
||||||
|
func TestHTMLExporter_Export_NilCourse(t *testing.T) {
|
||||||
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
outputPath := filepath.Join(tempDir, "nil-course.html")
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r == nil {
|
||||||
|
t.Error("Export(nil) expected panic, got none")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Export nil course - should panic
|
||||||
|
_ = exporter.Export(nil, outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTMLExporter_WriteHTML_NilCourse verifies that WriteHTML with nil course panics.
|
||||||
|
func TestHTMLExporter_WriteHTML_NilCourse(t *testing.T) {
|
||||||
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
htmlExporter := exporter.(*HTMLExporter)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r == nil {
|
||||||
|
t.Error("WriteHTML(nil) expected panic, got none")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
_ = htmlExporter.WriteHTML(&buf, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTMLExporter_WriteHTML_SuccessWithBuffer tests WriteHTML with a buffer.
|
||||||
|
func TestHTMLExporter_WriteHTML_SuccessWithBuffer(t *testing.T) {
|
||||||
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
htmlExporter := exporter.(*HTMLExporter)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
course *models.Course
|
||||||
|
contains []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic course",
|
||||||
|
course: createTestCourseForHTML(),
|
||||||
|
contains: []string{
|
||||||
|
"<!DOCTYPE html>",
|
||||||
|
"<html lang=\"en\">",
|
||||||
|
"Test Course",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "course with special characters",
|
||||||
|
course: &models.Course{
|
||||||
|
ShareID: "special-chars-id",
|
||||||
|
Course: models.CourseInfo{
|
||||||
|
ID: "special-course",
|
||||||
|
Title: "Course with <special> & \"characters\"",
|
||||||
|
Description: "<p>Description with & entities</p>",
|
||||||
|
Lessons: []models.Lesson{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contains: []string{
|
||||||
|
"<!DOCTYPE html>",
|
||||||
|
"Course with",
|
||||||
|
"special",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := htmlExporter.WriteHTML(&buf, tt.course)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteHTML() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
for _, want := range tt.contains {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Errorf("WriteHTML() output missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failingWriter is a test helper that fails after writing a certain number of bytes.
|
||||||
|
type failingWriter struct {
|
||||||
|
written int
|
||||||
|
failAfter int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *failingWriter) Write(p []byte) (n int, err error) {
|
||||||
|
if w.written >= w.failAfter {
|
||||||
|
return 0, errWriteFailed
|
||||||
|
}
|
||||||
|
w.written += len(p)
|
||||||
|
if w.written > w.failAfter {
|
||||||
|
return 0, errWriteFailed
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errWriteFailed = &writeError{msg: "simulated write failure"}
|
||||||
|
|
||||||
|
type writeError struct {
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *writeError) Error() string {
|
||||||
|
return e.msg
|
||||||
|
}
|
||||||
|
|
||||||
// createTestCourseForHTML creates a test course for HTML export tests.
|
// createTestCourseForHTML creates a test course for HTML export tests.
|
||||||
func createTestCourseForHTML() *models.Course {
|
func createTestCourseForHTML() *models.Course {
|
||||||
return &models.Course{
|
return &models.Course{
|
||||||
@@ -439,7 +660,7 @@ func createTestCourseForHTML() *models.Course {
|
|||||||
// BenchmarkHTMLExporter_Export benchmarks the Export method.
|
// BenchmarkHTMLExporter_Export benchmarks the Export method.
|
||||||
func BenchmarkHTMLExporter_Export(b *testing.B) {
|
func BenchmarkHTMLExporter_Export(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewHTMLExporter(htmlCleaner)
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
course := createTestCourseForHTML()
|
course := createTestCourseForHTML()
|
||||||
|
|
||||||
tempDir := b.TempDir()
|
tempDir := b.TempDir()
|
||||||
@@ -455,7 +676,7 @@ func BenchmarkHTMLExporter_Export(b *testing.B) {
|
|||||||
// BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course.
|
// BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course.
|
||||||
func BenchmarkHTMLExporter_ComplexCourse(b *testing.B) {
|
func BenchmarkHTMLExporter_ComplexCourse(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewHTMLExporter(htmlCleaner)
|
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create complex course for benchmarking
|
// Create complex course for benchmarking
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
|
|||||||
@@ -11,27 +11,26 @@ import (
|
|||||||
|
|
||||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||||
"github.com/kjanat/articulate-parser/internal/models"
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
"github.com/kjanat/articulate-parser/internal/services"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MarkdownExporter implements the Exporter interface for Markdown format.
|
// MarkdownExporter implements the Exporter interface for Markdown format.
|
||||||
// It converts Articulate Rise course data into a structured Markdown document.
|
// It converts Articulate Rise course data into a structured Markdown document.
|
||||||
type MarkdownExporter struct {
|
type MarkdownExporter struct {
|
||||||
// htmlCleaner is used to convert HTML content to plain text
|
baseExporter
|
||||||
htmlCleaner *services.HTMLCleaner
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMarkdownExporter creates a new MarkdownExporter instance.
|
// NewMarkdownExporter creates a new MarkdownExporter instance.
|
||||||
// It takes an HTMLCleaner to handle HTML content conversion.
|
// It takes an HTMLCleaner to handle HTML content conversion and a logger for logging.
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - htmlCleaner: Service for cleaning HTML content in course data
|
// - htmlCleaner: Service for cleaning HTML content in course data
|
||||||
|
// - logger: Logger instance for warnings and errors
|
||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - An implementation of the Exporter interface for Markdown format
|
// - An implementation of the Exporter interface for Markdown format
|
||||||
func NewMarkdownExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
|
func NewMarkdownExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
|
||||||
return &MarkdownExporter{
|
return &MarkdownExporter{
|
||||||
htmlCleaner: htmlCleaner,
|
baseExporter: newBaseExporter(htmlCleaner, logger),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,35 +39,39 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
// Write course header
|
// Write course header
|
||||||
buf.WriteString(fmt.Sprintf("# %s\n\n", course.Course.Title))
|
fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(course.Course.Title))
|
||||||
|
|
||||||
if course.Course.Description != "" {
|
if course.Course.Description != "" {
|
||||||
buf.WriteString(fmt.Sprintf("%s\n\n", e.htmlCleaner.CleanHTML(course.Course.Description)))
|
if desc := e.htmlCleaner.CleanAndTrim(course.Course.Description); desc != "" {
|
||||||
|
fmt.Fprintf(&buf, "%s\n\n", desc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add metadata
|
// Add metadata
|
||||||
buf.WriteString("## Course Information\n\n")
|
buf.WriteString("## Course Information\n\n")
|
||||||
buf.WriteString(fmt.Sprintf("- **Course ID**: %s\n", course.Course.ID))
|
fmt.Fprintf(&buf, "- **Course ID**: %s\n", strings.TrimSpace(course.Course.ID))
|
||||||
buf.WriteString(fmt.Sprintf("- **Share ID**: %s\n", course.ShareID))
|
fmt.Fprintf(&buf, "- **Share ID**: %s\n", strings.TrimSpace(course.ShareID))
|
||||||
buf.WriteString(fmt.Sprintf("- **Navigation Mode**: %s\n", course.Course.NavigationMode))
|
fmt.Fprintf(&buf, "- **Navigation Mode**: %s\n", strings.TrimSpace(course.Course.NavigationMode))
|
||||||
if course.Course.ExportSettings != nil {
|
if course.Course.ExportSettings != nil {
|
||||||
buf.WriteString(fmt.Sprintf("- **Export Format**: %s\n", course.Course.ExportSettings.Format))
|
fmt.Fprintf(&buf, "- **Export Format**: %s\n", strings.TrimSpace(course.Course.ExportSettings.Format))
|
||||||
}
|
}
|
||||||
buf.WriteString("\n---\n\n")
|
buf.WriteString("\n---\n\n")
|
||||||
|
|
||||||
// Process lessons
|
// Process lessons
|
||||||
lessonCounter := 0
|
lessonCounter := 0
|
||||||
for _, lesson := range course.Course.Lessons {
|
for _, lesson := range course.Course.Lessons {
|
||||||
if lesson.Type == "section" {
|
if lesson.Type == models.LessonTypeSection {
|
||||||
buf.WriteString(fmt.Sprintf("# %s\n\n", lesson.Title))
|
fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(lesson.Title))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
lessonCounter++
|
lessonCounter++
|
||||||
buf.WriteString(fmt.Sprintf("## Lesson %d: %s\n\n", lessonCounter, lesson.Title))
|
fmt.Fprintf(&buf, "## Lesson %d: %s\n\n", lessonCounter, strings.TrimSpace(lesson.Title))
|
||||||
|
|
||||||
if lesson.Description != "" {
|
if lesson.Description != "" {
|
||||||
buf.WriteString(fmt.Sprintf("%s\n\n", e.htmlCleaner.CleanHTML(lesson.Description)))
|
if desc := e.htmlCleaner.CleanAndTrim(lesson.Description); desc != "" {
|
||||||
|
fmt.Fprintf(&buf, "%s\n\n", desc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process lesson items
|
// Process lesson items
|
||||||
@@ -76,16 +79,49 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
|
|||||||
e.processItemToMarkdown(&buf, item, 3)
|
e.processItemToMarkdown(&buf, item, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.WriteString("\n---\n\n")
|
buf.WriteString("---\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up multiple consecutive blank lines before writing
|
||||||
|
output := cleanMarkdownWhitespace(buf.Bytes())
|
||||||
|
|
||||||
// #nosec G306 - 0644 is appropriate for export files that should be readable by others
|
// #nosec G306 - 0644 is appropriate for export files that should be readable by others
|
||||||
if err := os.WriteFile(outputPath, buf.Bytes(), 0o644); err != nil {
|
if err := os.WriteFile(outputPath, output, 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write markdown file: %w", err)
|
return fmt.Errorf("failed to write markdown file: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanMarkdownWhitespace normalizes whitespace in markdown output.
|
||||||
|
// It removes trailing whitespace from lines and collapses multiple blank lines into one.
|
||||||
|
func cleanMarkdownWhitespace(data []byte) []byte {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
result := make([]string, 0, len(lines))
|
||||||
|
prevBlank := false
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
// Trim trailing whitespace from each line
|
||||||
|
trimmed := strings.TrimRight(line, " \t")
|
||||||
|
|
||||||
|
// Collapse multiple consecutive blank lines
|
||||||
|
isBlank := trimmed == ""
|
||||||
|
if isBlank && prevBlank {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, trimmed)
|
||||||
|
prevBlank = isBlank
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing blank lines
|
||||||
|
for len(result) > 0 && result[len(result)-1] == "" {
|
||||||
|
result = result[:len(result)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure file ends with newline
|
||||||
|
return []byte(strings.Join(result, "\n") + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
// SupportedFormat returns "markdown".
|
// SupportedFormat returns "markdown".
|
||||||
func (e *MarkdownExporter) SupportedFormat() string {
|
func (e *MarkdownExporter) SupportedFormat() string {
|
||||||
return FormatMarkdown
|
return FormatMarkdown
|
||||||
@@ -96,20 +132,23 @@ func (e *MarkdownExporter) SupportedFormat() string {
|
|||||||
func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.Item, level int) {
|
func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.Item, level int) {
|
||||||
headingPrefix := strings.Repeat("#", level)
|
headingPrefix := strings.Repeat("#", level)
|
||||||
|
|
||||||
switch item.Type {
|
// Normalize item type to lowercase for consistent matching
|
||||||
case "text":
|
itemType := strings.ToLower(item.Type)
|
||||||
|
|
||||||
|
switch itemType {
|
||||||
|
case models.ItemTypeText:
|
||||||
e.processTextItem(buf, item, headingPrefix)
|
e.processTextItem(buf, item, headingPrefix)
|
||||||
case "list":
|
case models.ItemTypeList:
|
||||||
e.processListItem(buf, item)
|
e.processListItem(buf, item)
|
||||||
case "multimedia":
|
case models.ItemTypeMultimedia:
|
||||||
e.processMultimediaItem(buf, item, headingPrefix)
|
e.processMultimediaItem(buf, item, headingPrefix)
|
||||||
case "image":
|
case models.ItemTypeImage:
|
||||||
e.processImageItem(buf, item, headingPrefix)
|
e.processImageItem(buf, item, headingPrefix)
|
||||||
case "knowledgeCheck":
|
case models.ItemTypeKnowledgeCheck:
|
||||||
e.processKnowledgeCheckItem(buf, item, headingPrefix)
|
e.processKnowledgeCheckItem(buf, item, headingPrefix)
|
||||||
case "interactive":
|
case models.ItemTypeInteractive:
|
||||||
e.processInteractiveItem(buf, item, headingPrefix)
|
e.processInteractiveItem(buf, item, headingPrefix)
|
||||||
case "divider":
|
case models.ItemTypeDivider:
|
||||||
e.processDividerItem(buf)
|
e.processDividerItem(buf)
|
||||||
default:
|
default:
|
||||||
e.processUnknownItem(buf, item, headingPrefix)
|
e.processUnknownItem(buf, item, headingPrefix)
|
||||||
@@ -120,14 +159,12 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
|
|||||||
func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
||||||
for _, subItem := range item.Items {
|
for _, subItem := range item.Items {
|
||||||
if subItem.Heading != "" {
|
if subItem.Heading != "" {
|
||||||
heading := e.htmlCleaner.CleanHTML(subItem.Heading)
|
if heading := e.htmlCleaner.CleanAndTrim(subItem.Heading); heading != "" {
|
||||||
if heading != "" {
|
|
||||||
fmt.Fprintf(buf, "%s %s\n\n", headingPrefix, heading)
|
fmt.Fprintf(buf, "%s %s\n\n", headingPrefix, heading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if subItem.Paragraph != "" {
|
if subItem.Paragraph != "" {
|
||||||
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
|
||||||
if paragraph != "" {
|
|
||||||
fmt.Fprintf(buf, "%s\n\n", paragraph)
|
fmt.Fprintf(buf, "%s\n\n", paragraph)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,8 +175,7 @@ func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item,
|
|||||||
func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) {
|
func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) {
|
||||||
for _, subItem := range item.Items {
|
for _, subItem := range item.Items {
|
||||||
if subItem.Paragraph != "" {
|
if subItem.Paragraph != "" {
|
||||||
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
|
||||||
if paragraph != "" {
|
|
||||||
fmt.Fprintf(buf, "- %s\n", paragraph)
|
fmt.Fprintf(buf, "- %s\n", paragraph)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,8 +199,9 @@ func (e *MarkdownExporter) processMediaSubItem(buf *bytes.Buffer, subItem models
|
|||||||
e.processImageMedia(buf, subItem.Media)
|
e.processImageMedia(buf, subItem.Media)
|
||||||
}
|
}
|
||||||
if subItem.Caption != "" {
|
if subItem.Caption != "" {
|
||||||
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
|
if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); caption != "" {
|
||||||
fmt.Fprintf(buf, "*%s*\n", caption)
|
fmt.Fprintf(buf, "*%s*\n", caption)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,8 +230,9 @@ func (e *MarkdownExporter) processImageItem(buf *bytes.Buffer, item models.Item,
|
|||||||
fmt.Fprintf(buf, "**Image**: %s\n", subItem.Media.Image.OriginalURL)
|
fmt.Fprintf(buf, "**Image**: %s\n", subItem.Media.Image.OriginalURL)
|
||||||
}
|
}
|
||||||
if subItem.Caption != "" {
|
if subItem.Caption != "" {
|
||||||
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
|
if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); caption != "" {
|
||||||
fmt.Fprintf(buf, "*%s*\n", caption)
|
fmt.Fprintf(buf, "*%s*\n", caption)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buf.WriteString("\n")
|
buf.WriteString("\n")
|
||||||
@@ -212,27 +250,33 @@ func (e *MarkdownExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item mod
|
|||||||
// processQuestionSubItem processes individual question items.
|
// processQuestionSubItem processes individual question items.
|
||||||
func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) {
|
func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) {
|
||||||
if subItem.Title != "" {
|
if subItem.Title != "" {
|
||||||
title := e.htmlCleaner.CleanHTML(subItem.Title)
|
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
|
||||||
fmt.Fprintf(buf, "**Question**: %s\n\n", title)
|
fmt.Fprintf(buf, "**Question**: %s\n\n", title)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
e.processAnswers(buf, subItem.Answers)
|
e.processAnswers(buf, subItem.Answers)
|
||||||
|
|
||||||
if subItem.Feedback != "" {
|
if subItem.Feedback != "" {
|
||||||
feedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
|
if feedback := e.htmlCleaner.CleanAndTrim(subItem.Feedback); feedback != "" {
|
||||||
fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback)
|
fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// processAnswers processes answer choices for quiz questions.
|
// processAnswers processes answer choices for quiz questions.
|
||||||
func (e *MarkdownExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
|
func (e *MarkdownExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
|
||||||
|
if len(answers) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
buf.WriteString("**Answers**:\n")
|
buf.WriteString("**Answers**:\n")
|
||||||
for i, answer := range answers {
|
for i, answer := range answers {
|
||||||
correctMark := ""
|
correctMark := ""
|
||||||
if answer.Correct {
|
if answer.Correct {
|
||||||
correctMark = " ✓"
|
correctMark = " [correct]"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(buf, "%d. %s%s\n", i+1, answer.Title, correctMark)
|
title := strings.TrimSpace(answer.Title)
|
||||||
|
fmt.Fprintf(buf, "%d. %s%s\n", i+1, title, correctMark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,8 +285,9 @@ func (e *MarkdownExporter) processInteractiveItem(buf *bytes.Buffer, item models
|
|||||||
fmt.Fprintf(buf, "%s Interactive Content\n\n", headingPrefix)
|
fmt.Fprintf(buf, "%s Interactive Content\n\n", headingPrefix)
|
||||||
for _, subItem := range item.Items {
|
for _, subItem := range item.Items {
|
||||||
if subItem.Title != "" {
|
if subItem.Title != "" {
|
||||||
title := e.htmlCleaner.CleanHTML(subItem.Title)
|
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
|
||||||
fmt.Fprintf(buf, "**%s**\n\n", title)
|
fmt.Fprintf(buf, "**%s**\n\n", title)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,11 +311,13 @@ func (e *MarkdownExporter) processUnknownItem(buf *bytes.Buffer, item models.Ite
|
|||||||
// processGenericSubItem processes sub-items for unknown types.
|
// processGenericSubItem processes sub-items for unknown types.
|
||||||
func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
|
func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
|
||||||
if subItem.Title != "" {
|
if subItem.Title != "" {
|
||||||
title := e.htmlCleaner.CleanHTML(subItem.Title)
|
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
|
||||||
fmt.Fprintf(buf, "**%s**\n\n", title)
|
fmt.Fprintf(buf, "**%s**\n\n", title)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if subItem.Paragraph != "" {
|
if subItem.Paragraph != "" {
|
||||||
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
|
||||||
fmt.Fprintf(buf, "%s\n\n", paragraph)
|
fmt.Fprintf(buf, "%s\n\n", paragraph)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
// TestNewMarkdownExporter tests the NewMarkdownExporter constructor.
|
// TestNewMarkdownExporter tests the NewMarkdownExporter constructor.
|
||||||
func TestNewMarkdownExporter(t *testing.T) {
|
func TestNewMarkdownExporter(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewMarkdownExporter(htmlCleaner)
|
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
if exporter == nil {
|
if exporter == nil {
|
||||||
t.Fatal("NewMarkdownExporter() returned nil")
|
t.Fatal("NewMarkdownExporter() returned nil")
|
||||||
@@ -34,7 +34,7 @@ func TestNewMarkdownExporter(t *testing.T) {
|
|||||||
// TestMarkdownExporter_SupportedFormat tests the SupportedFormat method.
|
// TestMarkdownExporter_SupportedFormat tests the SupportedFormat method.
|
||||||
func TestMarkdownExporter_SupportedFormat(t *testing.T) {
|
func TestMarkdownExporter_SupportedFormat(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewMarkdownExporter(htmlCleaner)
|
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
expected := "markdown"
|
expected := "markdown"
|
||||||
result := exporter.SupportedFormat()
|
result := exporter.SupportedFormat()
|
||||||
@@ -47,7 +47,7 @@ func TestMarkdownExporter_SupportedFormat(t *testing.T) {
|
|||||||
// TestMarkdownExporter_Export tests the Export method.
|
// TestMarkdownExporter_Export tests the Export method.
|
||||||
func TestMarkdownExporter_Export(t *testing.T) {
|
func TestMarkdownExporter_Export(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewMarkdownExporter(htmlCleaner)
|
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create test course
|
// Create test course
|
||||||
testCourse := createTestCourseForMarkdown()
|
testCourse := createTestCourseForMarkdown()
|
||||||
@@ -108,7 +108,7 @@ func TestMarkdownExporter_Export(t *testing.T) {
|
|||||||
// TestMarkdownExporter_Export_InvalidPath tests export with invalid output path.
|
// TestMarkdownExporter_Export_InvalidPath tests export with invalid output path.
|
||||||
func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
|
func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewMarkdownExporter(htmlCleaner)
|
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
testCourse := createTestCourseForMarkdown()
|
testCourse := createTestCourseForMarkdown()
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ProcessTextItem tests the processTextItem method.
|
// TestMarkdownExporter_ProcessTextItem tests the processTextItem method.
|
||||||
func TestMarkdownExporter_ProcessTextItem(t *testing.T) {
|
func TestMarkdownExporter_ProcessTextItem(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
item := models.Item{
|
item := models.Item{
|
||||||
@@ -153,7 +153,7 @@ func TestMarkdownExporter_ProcessTextItem(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ProcessListItem tests the processListItem method.
|
// TestMarkdownExporter_ProcessListItem tests the processListItem method.
|
||||||
func TestMarkdownExporter_ProcessListItem(t *testing.T) {
|
func TestMarkdownExporter_ProcessListItem(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
item := models.Item{
|
item := models.Item{
|
||||||
@@ -178,7 +178,7 @@ func TestMarkdownExporter_ProcessListItem(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ProcessMultimediaItem tests the processMultimediaItem method.
|
// TestMarkdownExporter_ProcessMultimediaItem tests the processMultimediaItem method.
|
||||||
func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) {
|
func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
item := models.Item{
|
item := models.Item{
|
||||||
@@ -217,7 +217,7 @@ func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ProcessImageItem tests the processImageItem method.
|
// TestMarkdownExporter_ProcessImageItem tests the processImageItem method.
|
||||||
func TestMarkdownExporter_ProcessImageItem(t *testing.T) {
|
func TestMarkdownExporter_ProcessImageItem(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
item := models.Item{
|
item := models.Item{
|
||||||
@@ -252,7 +252,7 @@ func TestMarkdownExporter_ProcessImageItem(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method.
|
// TestMarkdownExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method.
|
||||||
func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
|
func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
item := models.Item{
|
item := models.Item{
|
||||||
@@ -283,7 +283,7 @@ func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
|
|||||||
if !strings.Contains(result, "**Answers**:") {
|
if !strings.Contains(result, "**Answers**:") {
|
||||||
t.Error("Should contain answers heading")
|
t.Error("Should contain answers heading")
|
||||||
}
|
}
|
||||||
if !strings.Contains(result, "2. Paris ✓") {
|
if !strings.Contains(result, "2. Paris [correct]") {
|
||||||
t.Error("Should mark correct answer")
|
t.Error("Should mark correct answer")
|
||||||
}
|
}
|
||||||
if !strings.Contains(result, "**Feedback**: Paris is the capital of France.") {
|
if !strings.Contains(result, "**Feedback**: Paris is the capital of France.") {
|
||||||
@@ -294,7 +294,7 @@ func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ProcessInteractiveItem tests the processInteractiveItem method.
|
// TestMarkdownExporter_ProcessInteractiveItem tests the processInteractiveItem method.
|
||||||
func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) {
|
func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
item := models.Item{
|
item := models.Item{
|
||||||
@@ -319,7 +319,7 @@ func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ProcessDividerItem tests the processDividerItem method.
|
// TestMarkdownExporter_ProcessDividerItem tests the processDividerItem method.
|
||||||
func TestMarkdownExporter_ProcessDividerItem(t *testing.T) {
|
func TestMarkdownExporter_ProcessDividerItem(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
exporter.processDividerItem(&buf)
|
exporter.processDividerItem(&buf)
|
||||||
@@ -335,7 +335,7 @@ func TestMarkdownExporter_ProcessDividerItem(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ProcessUnknownItem tests the processUnknownItem method.
|
// TestMarkdownExporter_ProcessUnknownItem tests the processUnknownItem method.
|
||||||
func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) {
|
func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
item := models.Item{
|
item := models.Item{
|
||||||
@@ -366,7 +366,7 @@ func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ProcessVideoMedia tests the processVideoMedia method.
|
// TestMarkdownExporter_ProcessVideoMedia tests the processVideoMedia method.
|
||||||
func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) {
|
func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
media := &models.Media{
|
media := &models.Media{
|
||||||
@@ -391,7 +391,7 @@ func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ProcessImageMedia tests the processImageMedia method.
|
// TestMarkdownExporter_ProcessImageMedia tests the processImageMedia method.
|
||||||
func TestMarkdownExporter_ProcessImageMedia(t *testing.T) {
|
func TestMarkdownExporter_ProcessImageMedia(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
media := &models.Media{
|
media := &models.Media{
|
||||||
@@ -413,7 +413,7 @@ func TestMarkdownExporter_ProcessImageMedia(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ProcessAnswers tests the processAnswers method.
|
// TestMarkdownExporter_ProcessAnswers tests the processAnswers method.
|
||||||
func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
|
func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
answers := []models.Answer{
|
answers := []models.Answer{
|
||||||
@@ -432,7 +432,7 @@ func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
|
|||||||
if !strings.Contains(result, "1. Answer 1") {
|
if !strings.Contains(result, "1. Answer 1") {
|
||||||
t.Error("Should contain first answer")
|
t.Error("Should contain first answer")
|
||||||
}
|
}
|
||||||
if !strings.Contains(result, "2. Answer 2 ✓") {
|
if !strings.Contains(result, "2. Answer 2 [correct]") {
|
||||||
t.Error("Should mark correct answer")
|
t.Error("Should mark correct answer")
|
||||||
}
|
}
|
||||||
if !strings.Contains(result, "3. Answer 3") {
|
if !strings.Contains(result, "3. Answer 3") {
|
||||||
@@ -443,7 +443,7 @@ func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ProcessItemToMarkdown_AllTypes tests all item types.
|
// TestMarkdownExporter_ProcessItemToMarkdown_AllTypes tests all item types.
|
||||||
func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
|
func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -510,7 +510,7 @@ func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
|
|||||||
// TestMarkdownExporter_ComplexCourse tests export of a complex course structure.
|
// TestMarkdownExporter_ComplexCourse tests export of a complex course structure.
|
||||||
func TestMarkdownExporter_ComplexCourse(t *testing.T) {
|
func TestMarkdownExporter_ComplexCourse(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewMarkdownExporter(htmlCleaner)
|
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||||
|
|
||||||
// Create complex test course
|
// Create complex test course
|
||||||
course := &models.Course{
|
course := &models.Course{
|
||||||
@@ -603,7 +603,7 @@ func TestMarkdownExporter_ComplexCourse(t *testing.T) {
|
|||||||
"- Second objective",
|
"- Second objective",
|
||||||
"### Knowledge Check",
|
"### Knowledge Check",
|
||||||
"**Question**: What will you learn?",
|
"**Question**: What will you learn?",
|
||||||
"2. Everything ✓",
|
"2. Everything [correct]",
|
||||||
"**Feedback**: Great choice!",
|
"**Feedback**: Great choice!",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,7 +654,7 @@ func createTestCourseForMarkdown() *models.Course {
|
|||||||
// BenchmarkMarkdownExporter_Export benchmarks the Export method.
|
// BenchmarkMarkdownExporter_Export benchmarks the Export method.
|
||||||
func BenchmarkMarkdownExporter_Export(b *testing.B) {
|
func BenchmarkMarkdownExporter_Export(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewMarkdownExporter(htmlCleaner)
|
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||||
course := createTestCourseForMarkdown()
|
course := createTestCourseForMarkdown()
|
||||||
|
|
||||||
// Create temporary directory
|
// Create temporary directory
|
||||||
@@ -673,7 +673,7 @@ func BenchmarkMarkdownExporter_Export(b *testing.B) {
|
|||||||
// BenchmarkMarkdownExporter_ProcessTextItem benchmarks the processTextItem method.
|
// BenchmarkMarkdownExporter_ProcessTextItem benchmarks the processTextItem method.
|
||||||
func BenchmarkMarkdownExporter_ProcessTextItem(b *testing.B) {
|
func BenchmarkMarkdownExporter_ProcessTextItem(b *testing.B) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||||
|
|
||||||
item := models.Item{
|
item := models.Item{
|
||||||
Type: "text",
|
Type: "text",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,12 +0,0 @@
|
|||||||
# Example Course
|
|
||||||
|
|
||||||
Course description
|
|
||||||
|
|
||||||
## Course Information
|
|
||||||
|
|
||||||
- **Course ID**:
|
|
||||||
- **Share ID**: example-id
|
|
||||||
- **Navigation Mode**:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package interfaces
|
||||||
|
|
||||||
|
// HTMLCleaner defines the interface for cleaning HTML content.
|
||||||
|
// Implementations convert HTML to plain text, stripping tags and decoding entities.
|
||||||
|
type HTMLCleaner interface {
|
||||||
|
// CleanHTML removes HTML tags and decodes HTML entities from a string.
|
||||||
|
// Returns plain text suitable for non-HTML output formats.
|
||||||
|
CleanHTML(htmlStr string) string
|
||||||
|
|
||||||
|
// CleanAndTrim removes HTML tags, decodes entities, and trims whitespace.
|
||||||
|
// Convenience method combining CleanHTML with strings.TrimSpace.
|
||||||
|
CleanAndTrim(htmlStr string) string
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
// These structures closely match the JSON format used by Articulate Rise.
|
// These structures closely match the JSON format used by Articulate Rise.
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
// Course represents the top-level structure of an Articulate Rise course.
|
// Course represents the top-level structure of an Articulate Rise course.
|
||||||
// It contains metadata and the actual course content.
|
// It contains metadata and the actual course content.
|
||||||
type Course struct {
|
type Course struct {
|
||||||
@@ -45,6 +47,8 @@ type ExportSettings struct {
|
|||||||
|
|
||||||
// LabelSet contains customized labels used throughout the course.
|
// LabelSet contains customized labels used throughout the course.
|
||||||
// This allows course creators to modify standard terminology.
|
// This allows course creators to modify standard terminology.
|
||||||
|
//
|
||||||
|
// NOTE: Currently parsed but not used in export logic.
|
||||||
type LabelSet struct {
|
type LabelSet struct {
|
||||||
// ID is the unique identifier for this label set
|
// ID is the unique identifier for this label set
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -53,3 +57,46 @@ type LabelSet struct {
|
|||||||
// Labels is a mapping of label keys to their customized values
|
// Labels is a mapping of label keys to their customized values
|
||||||
Labels map[string]string `json:"labels"`
|
Labels map[string]string `json:"labels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate checks whether the Course structure contains valid data.
|
||||||
|
// It returns an error describing the first validation failure found,
|
||||||
|
// or nil if the course is valid.
|
||||||
|
//
|
||||||
|
// Validation rules:
|
||||||
|
// - ShareID must not be empty
|
||||||
|
// - Course.ID must not be empty
|
||||||
|
// - Course.Title must not be empty
|
||||||
|
// - Each Lesson must have a non-empty ID and Title
|
||||||
|
func (c *Course) Validate() error {
|
||||||
|
if c.ShareID == "" {
|
||||||
|
return &ValidationError{Field: "ShareID", Message: "share ID is required"}
|
||||||
|
}
|
||||||
|
if c.Course.ID == "" {
|
||||||
|
return &ValidationError{Field: "Course.ID", Message: "course ID is required"}
|
||||||
|
}
|
||||||
|
if c.Course.Title == "" {
|
||||||
|
return &ValidationError{Field: "Course.Title", Message: "course title is required"}
|
||||||
|
}
|
||||||
|
for i, lesson := range c.Course.Lessons {
|
||||||
|
if lesson.ID == "" {
|
||||||
|
return &ValidationError{Field: "Course.Lessons", Message: fmt.Sprintf("lesson at index %d has empty ID", i)}
|
||||||
|
}
|
||||||
|
if lesson.Title == "" {
|
||||||
|
return &ValidationError{Field: "Course.Lessons", Message: fmt.Sprintf("lesson at index %d has empty title", i)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationError represents a validation failure for a specific field.
|
||||||
|
type ValidationError struct {
|
||||||
|
// Field is the name of the field that failed validation
|
||||||
|
Field string
|
||||||
|
// Message describes the validation failure
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements the error interface for ValidationError.
|
||||||
|
func (e *ValidationError) Error() string {
|
||||||
|
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// Item type constants used throughout the application.
|
||||||
|
// These match the type field values in Articulate Rise course data.
|
||||||
|
const (
|
||||||
|
// ItemTypeText represents text content blocks with headings and paragraphs.
|
||||||
|
ItemTypeText = "text"
|
||||||
|
|
||||||
|
// ItemTypeList represents bullet or numbered list content.
|
||||||
|
ItemTypeList = "list"
|
||||||
|
|
||||||
|
// ItemTypeKnowledgeCheck represents quiz or assessment items.
|
||||||
|
ItemTypeKnowledgeCheck = "knowledgecheck"
|
||||||
|
|
||||||
|
// ItemTypeMultimedia represents video or audio content.
|
||||||
|
ItemTypeMultimedia = "multimedia"
|
||||||
|
|
||||||
|
// ItemTypeImage represents standalone image content.
|
||||||
|
ItemTypeImage = "image"
|
||||||
|
|
||||||
|
// ItemTypeInteractive represents interactive elements like accordions or tabs.
|
||||||
|
ItemTypeInteractive = "interactive"
|
||||||
|
|
||||||
|
// ItemTypeDivider represents visual separator elements.
|
||||||
|
ItemTypeDivider = "divider"
|
||||||
|
|
||||||
|
// ItemTypeQuote represents quotation blocks.
|
||||||
|
ItemTypeQuote = "quote"
|
||||||
|
|
||||||
|
// ItemTypeFlipcard represents flashcard-style content.
|
||||||
|
ItemTypeFlipcard = "flipcard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lesson type constants.
|
||||||
|
const (
|
||||||
|
// LessonTypeSection represents a section header (not a real lesson).
|
||||||
|
LessonTypeSection = "section"
|
||||||
|
|
||||||
|
// LessonTypeLesson represents a regular lesson.
|
||||||
|
LessonTypeLesson = "lesson"
|
||||||
|
)
|
||||||
@@ -15,7 +15,8 @@ type Lesson struct {
|
|||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
// Items is an ordered array of content items within the lesson
|
// Items is an ordered array of content items within the lesson
|
||||||
Items []Item `json:"items"`
|
Items []Item `json:"items"`
|
||||||
// Position stores the ordering information for the lesson
|
// Position stores the ordering information for the lesson.
|
||||||
|
// NOTE: Currently parsed but not used in export logic.
|
||||||
Position any `json:"position"`
|
Position any `json:"position"`
|
||||||
// Ready indicates whether the lesson is marked as complete
|
// Ready indicates whether the lesson is marked as complete
|
||||||
Ready bool `json:"ready"`
|
Ready bool `json:"ready"`
|
||||||
@@ -38,9 +39,11 @@ type Item struct {
|
|||||||
Variant string `json:"variant"`
|
Variant string `json:"variant"`
|
||||||
// Items contains the actual content elements (sub-items) of this item
|
// Items contains the actual content elements (sub-items) of this item
|
||||||
Items []SubItem `json:"items"`
|
Items []SubItem `json:"items"`
|
||||||
// Settings contains configuration options specific to this item type
|
// Settings contains configuration options specific to this item type.
|
||||||
|
// NOTE: Currently parsed but not used in export logic.
|
||||||
Settings any `json:"settings"`
|
Settings any `json:"settings"`
|
||||||
// Data contains additional structured data for the item
|
// Data contains additional structured data for the item.
|
||||||
|
// NOTE: Currently parsed but not used in export logic.
|
||||||
Data any `json:"data"`
|
Data any `json:"data"`
|
||||||
// Media contains any associated media for the item
|
// Media contains any associated media for the item
|
||||||
Media *Media `json:"media,omitempty"`
|
Media *Media `json:"media,omitempty"`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -664,6 +665,161 @@ func BenchmarkCourse_JSONUnmarshal(b *testing.B) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCourse_Validate tests the Course.Validate method.
|
||||||
|
func TestCourse_Validate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
course Course
|
||||||
|
wantErr bool
|
||||||
|
errField string
|
||||||
|
errSubstr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid course",
|
||||||
|
course: Course{
|
||||||
|
ShareID: "test-share-id",
|
||||||
|
Course: CourseInfo{
|
||||||
|
ID: "course-123",
|
||||||
|
Title: "Test Course",
|
||||||
|
Lessons: []Lesson{
|
||||||
|
{ID: "lesson-1", Title: "First Lesson"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid course with no lessons",
|
||||||
|
course: Course{
|
||||||
|
ShareID: "test-share-id",
|
||||||
|
Course: CourseInfo{
|
||||||
|
ID: "course-123",
|
||||||
|
Title: "Test Course",
|
||||||
|
Lessons: []Lesson{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing share ID",
|
||||||
|
course: Course{
|
||||||
|
Course: CourseInfo{
|
||||||
|
ID: "course-123",
|
||||||
|
Title: "Test Course",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errField: "ShareID",
|
||||||
|
errSubstr: "share ID is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing course ID",
|
||||||
|
course: Course{
|
||||||
|
ShareID: "test-share-id",
|
||||||
|
Course: CourseInfo{
|
||||||
|
Title: "Test Course",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errField: "Course.ID",
|
||||||
|
errSubstr: "course ID is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing course title",
|
||||||
|
course: Course{
|
||||||
|
ShareID: "test-share-id",
|
||||||
|
Course: CourseInfo{
|
||||||
|
ID: "course-123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errField: "Course.Title",
|
||||||
|
errSubstr: "course title is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lesson with empty ID",
|
||||||
|
course: Course{
|
||||||
|
ShareID: "test-share-id",
|
||||||
|
Course: CourseInfo{
|
||||||
|
ID: "course-123",
|
||||||
|
Title: "Test Course",
|
||||||
|
Lessons: []Lesson{
|
||||||
|
{ID: "", Title: "First Lesson"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errField: "Course.Lessons",
|
||||||
|
errSubstr: "lesson at index 0 has empty ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lesson with empty title",
|
||||||
|
course: Course{
|
||||||
|
ShareID: "test-share-id",
|
||||||
|
Course: CourseInfo{
|
||||||
|
ID: "course-123",
|
||||||
|
Title: "Test Course",
|
||||||
|
Lessons: []Lesson{
|
||||||
|
{ID: "lesson-1", Title: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errField: "Course.Lessons",
|
||||||
|
errSubstr: "lesson at index 0 has empty title",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "second lesson invalid",
|
||||||
|
course: Course{
|
||||||
|
ShareID: "test-share-id",
|
||||||
|
Course: CourseInfo{
|
||||||
|
ID: "course-123",
|
||||||
|
Title: "Test Course",
|
||||||
|
Lessons: []Lesson{
|
||||||
|
{ID: "lesson-1", Title: "First Lesson"},
|
||||||
|
{ID: "lesson-2", Title: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errField: "Course.Lessons",
|
||||||
|
errSubstr: "lesson at index 1 has empty title",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.course.Validate()
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("Course.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
var valErr *ValidationError
|
||||||
|
if ok := errors.As(err, &valErr); !ok {
|
||||||
|
t.Errorf("Expected ValidationError, got %T", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if valErr.Field != tt.errField {
|
||||||
|
t.Errorf("ValidationError.Field = %q, want %q", valErr.Field, tt.errField)
|
||||||
|
}
|
||||||
|
if valErr.Message != tt.errSubstr {
|
||||||
|
t.Errorf("ValidationError.Message = %q, want %q", valErr.Message, tt.errSubstr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidationError_Error tests the ValidationError.Error method.
|
||||||
|
func TestValidationError_Error(t *testing.T) {
|
||||||
|
err := &ValidationError{Field: "TestField", Message: "test message"}
|
||||||
|
expected := "validation error on TestField: test message"
|
||||||
|
if err.Error() != expected {
|
||||||
|
t.Errorf("ValidationError.Error() = %q, want %q", err.Error(), expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// compareMaps compares two any values that should be maps.
|
// compareMaps compares two any values that should be maps.
|
||||||
func compareMaps(original, unmarshaled any) bool {
|
func compareMaps(original, unmarshaled any) bool {
|
||||||
origMap, origOk := original.(map[string]any)
|
origMap, origOk := original.(map[string]any)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/apperrors"
|
||||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||||
"github.com/kjanat/articulate-parser/internal/models"
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
)
|
)
|
||||||
@@ -31,33 +32,46 @@ func NewApp(parser interfaces.CourseParser, exporterFactory interfaces.ExporterF
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProcessCourseFromFile loads a course from a local file and exports it to the specified format.
|
// ProcessCourseFromFile loads a course from a local file and exports it to the specified format.
|
||||||
// It takes the path to the course file, the desired export format, and the output file path.
|
// It takes a context for cancellation, the path to the course file, the desired export format,
|
||||||
// Returns an error if loading or exporting fails.
|
// and the output file path. Returns an error if loading or exporting fails, or if the context
|
||||||
func (a *App) ProcessCourseFromFile(filePath, format, outputPath string) error {
|
// is canceled.
|
||||||
|
func (a *App) ProcessCourseFromFile(ctx context.Context, filePath, format, outputPath string) error {
|
||||||
|
// Check for cancellation before loading
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", apperrors.ErrOperationCanceled, err)
|
||||||
|
}
|
||||||
|
|
||||||
course, err := a.parser.LoadCourseFromFile(filePath)
|
course, err := a.parser.LoadCourseFromFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load course from file: %w", err)
|
return fmt.Errorf("failed to load course from file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.exportCourse(course, format, outputPath)
|
return a.exportCourse(ctx, course, format, outputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessCourseFromURI fetches a course from the provided URI and exports it to the specified format.
|
// ProcessCourseFromURI fetches a course from the provided URI and exports it to the specified format.
|
||||||
// It takes the URI to fetch the course from, the desired export format, and the output file path.
|
// It takes a context for cancellation, the URI to fetch the course from, the desired export format,
|
||||||
// Returns an error if fetching or exporting fails.
|
// and the output file path. Returns an error if fetching or exporting fails, or if the context
|
||||||
|
// is canceled.
|
||||||
func (a *App) ProcessCourseFromURI(ctx context.Context, uri, format, outputPath string) error {
|
func (a *App) ProcessCourseFromURI(ctx context.Context, uri, format, outputPath string) error {
|
||||||
course, err := a.parser.FetchCourse(ctx, uri)
|
course, err := a.parser.FetchCourse(ctx, uri)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to fetch course: %w", err)
|
return fmt.Errorf("failed to fetch course: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.exportCourse(course, format, outputPath)
|
return a.exportCourse(ctx, course, format, outputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// exportCourse exports a course to the specified format and output path.
|
// exportCourse exports a course to the specified format and output path.
|
||||||
// It's a helper method that creates the appropriate exporter and performs the export.
|
// It's a helper method that creates the appropriate exporter and performs the export.
|
||||||
// Returns an error if creating the exporter or exporting the course fails.
|
// Returns an error if creating the exporter or exporting the course fails, or if the
|
||||||
func (a *App) exportCourse(course *models.Course, format, outputPath string) error {
|
// context is canceled.
|
||||||
|
func (a *App) exportCourse(ctx context.Context, course *models.Course, format, outputPath string) error {
|
||||||
|
// Check for cancellation before export
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", apperrors.ErrOperationCanceled, err)
|
||||||
|
}
|
||||||
|
|
||||||
exporter, err := a.exporterFactory.CreateExporter(format)
|
exporter, err := a.exporterFactory.CreateExporter(format)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create exporter: %w", err)
|
return fmt.Errorf("failed to create exporter: %w", err)
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ func TestApp_ProcessCourseFromFile(t *testing.T) {
|
|||||||
tt.setupMocks(parser, factory, exporter)
|
tt.setupMocks(parser, factory, exporter)
|
||||||
|
|
||||||
app := NewApp(parser, factory)
|
app := NewApp(parser, factory)
|
||||||
err := app.ProcessCourseFromFile(tt.filePath, tt.format, tt.outputPath)
|
err := app.ProcessCourseFromFile(context.Background(), tt.filePath, tt.format, tt.outputPath)
|
||||||
|
|
||||||
if tt.expectedError != "" {
|
if tt.expectedError != "" {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/config"
|
||||||
"github.com/kjanat/articulate-parser/internal/services"
|
"github.com/kjanat/articulate-parser/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,8 +17,8 @@ func ExampleNewArticulateParser() {
|
|||||||
// Create a no-op logger for this example
|
// Create a no-op logger for this example
|
||||||
logger := services.NewNoOpLogger()
|
logger := services.NewNoOpLogger()
|
||||||
|
|
||||||
// Create parser with defaults
|
// Create parser with defaults (nil config uses defaults)
|
||||||
parser := services.NewArticulateParser(logger, "", 0)
|
parser := services.NewArticulateParser(logger, nil)
|
||||||
|
|
||||||
fmt.Printf("Parser created: %T\n", parser)
|
fmt.Printf("Parser created: %T\n", parser)
|
||||||
// Output: Parser created: *services.ArticulateParser
|
// Output: Parser created: *services.ArticulateParser
|
||||||
@@ -26,11 +29,11 @@ func ExampleNewArticulateParser_custom() {
|
|||||||
logger := services.NewNoOpLogger()
|
logger := services.NewNoOpLogger()
|
||||||
|
|
||||||
// Create parser with custom base URL and timeout
|
// Create parser with custom base URL and timeout
|
||||||
parser := services.NewArticulateParser(
|
cfg := &config.Config{
|
||||||
logger,
|
BaseURL: "https://custom.articulate.com",
|
||||||
"https://custom.articulate.com",
|
RequestTimeout: 60 * time.Second,
|
||||||
60_000_000_000, // 60 seconds in nanoseconds
|
}
|
||||||
)
|
parser := services.NewArticulateParser(logger, cfg)
|
||||||
|
|
||||||
fmt.Printf("Parser configured: %T\n", parser)
|
fmt.Printf("Parser configured: %T\n", parser)
|
||||||
// Output: Parser configured: *services.ArticulateParser
|
// Output: Parser configured: *services.ArticulateParser
|
||||||
@@ -39,7 +42,7 @@ func ExampleNewArticulateParser_custom() {
|
|||||||
// ExampleArticulateParser_LoadCourseFromFile demonstrates loading a course from a file.
|
// ExampleArticulateParser_LoadCourseFromFile demonstrates loading a course from a file.
|
||||||
func ExampleArticulateParser_LoadCourseFromFile() {
|
func ExampleArticulateParser_LoadCourseFromFile() {
|
||||||
logger := services.NewNoOpLogger()
|
logger := services.NewNoOpLogger()
|
||||||
parser := services.NewArticulateParser(logger, "", 0)
|
parser := services.NewArticulateParser(logger, nil)
|
||||||
|
|
||||||
// In a real scenario, you'd have an actual file
|
// In a real scenario, you'd have an actual file
|
||||||
// This example shows the API usage
|
// This example shows the API usage
|
||||||
@@ -52,7 +55,7 @@ func ExampleArticulateParser_LoadCourseFromFile() {
|
|||||||
// ExampleArticulateParser_FetchCourse demonstrates fetching a course from a URI.
|
// ExampleArticulateParser_FetchCourse demonstrates fetching a course from a URI.
|
||||||
func ExampleArticulateParser_FetchCourse() {
|
func ExampleArticulateParser_FetchCourse() {
|
||||||
logger := services.NewNoOpLogger()
|
logger := services.NewNoOpLogger()
|
||||||
parser := services.NewArticulateParser(logger, "", 0)
|
parser := services.NewArticulateParser(logger, nil)
|
||||||
|
|
||||||
// Create a context with timeout
|
// Create a context with timeout
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -64,6 +67,54 @@ func ExampleArticulateParser_FetchCourse() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExampleNewSlogLogger demonstrates creating a JSON logger.
|
||||||
|
func ExampleNewSlogLogger() {
|
||||||
|
// Create a JSON structured logger at Info level
|
||||||
|
// Output goes to stdout in JSON format
|
||||||
|
logger := services.NewSlogLogger(slog.LevelInfo)
|
||||||
|
|
||||||
|
// Logger is ready to use - calls like logger.Info("msg") output JSON
|
||||||
|
fmt.Printf("Logger type: %T\n", logger)
|
||||||
|
// Output: Logger type: *services.SlogLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExampleNewTextLogger demonstrates creating a human-readable text logger.
|
||||||
|
func ExampleNewTextLogger() {
|
||||||
|
// Create a text logger, more readable for development
|
||||||
|
logger := services.NewTextLogger(slog.LevelDebug)
|
||||||
|
|
||||||
|
// Logger is ready - calls like logger.Debug("msg") output text
|
||||||
|
fmt.Printf("Logger type: %T\n", logger)
|
||||||
|
// Output: Logger type: *services.SlogLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExampleNewNoOpLogger demonstrates creating a no-op logger for testing.
|
||||||
|
func ExampleNewNoOpLogger() {
|
||||||
|
logger := services.NewNoOpLogger()
|
||||||
|
|
||||||
|
// All log calls are silently discarded
|
||||||
|
logger.Debug("this won't appear")
|
||||||
|
logger.Info("neither will this")
|
||||||
|
logger.Error("or this")
|
||||||
|
|
||||||
|
fmt.Println("Logging silently completed")
|
||||||
|
// Output: Logging silently completed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExampleSlogLogger_With demonstrates creating a child logger with context.
|
||||||
|
func ExampleSlogLogger_With() {
|
||||||
|
logger := services.NewNoOpLogger()
|
||||||
|
|
||||||
|
// Create a child logger with additional context
|
||||||
|
requestLogger := logger.With("request_id", "abc123", "user", "john")
|
||||||
|
|
||||||
|
// All logs from requestLogger will include request_id and user
|
||||||
|
requestLogger.Info("processing request")
|
||||||
|
|
||||||
|
fmt.Printf("Child logger type: %T\n", requestLogger)
|
||||||
|
// Output: Child logger type: *services.NoOpLogger
|
||||||
|
}
|
||||||
|
|
||||||
// ExampleHTMLCleaner demonstrates cleaning HTML content.
|
// ExampleHTMLCleaner demonstrates cleaning HTML content.
|
||||||
func ExampleHTMLCleaner() {
|
func ExampleHTMLCleaner() {
|
||||||
cleaner := services.NewHTMLCleaner()
|
cleaner := services.NewHTMLCleaner()
|
||||||
|
|||||||
@@ -7,13 +7,19 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTMLCleaner provides utilities for converting HTML content to plain text.
|
// HTMLCleaner provides utilities for converting HTML content to plain text.
|
||||||
// It removes HTML tags while preserving their content and converts HTML entities
|
// It removes HTML tags while preserving their content and converts HTML entities
|
||||||
// to their plain text equivalents using proper HTML parsing instead of regex.
|
// to their plain text equivalents using proper HTML parsing instead of regex.
|
||||||
|
// Implements interfaces.HTMLCleaner.
|
||||||
type HTMLCleaner struct{}
|
type HTMLCleaner struct{}
|
||||||
|
|
||||||
|
// Compile-time check that HTMLCleaner implements interfaces.HTMLCleaner.
|
||||||
|
var _ interfaces.HTMLCleaner = (*HTMLCleaner)(nil)
|
||||||
|
|
||||||
// NewHTMLCleaner creates a new HTML cleaner instance.
|
// NewHTMLCleaner creates a new HTML cleaner instance.
|
||||||
// This service is typically injected into exporters that need to handle
|
// This service is typically injected into exporters that need to handle
|
||||||
// HTML content from Articulate Rise courses.
|
// HTML content from Articulate Rise courses.
|
||||||
@@ -46,6 +52,13 @@ func (h *HTMLCleaner) CleanHTML(htmlStr string) string {
|
|||||||
return strings.TrimSpace(cleaned)
|
return strings.TrimSpace(cleaned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CleanAndTrim removes HTML tags, decodes entities, and trims whitespace.
|
||||||
|
// Convenience method that combines CleanHTML with strings.TrimSpace.
|
||||||
|
// Returns empty string if input is empty or contains only whitespace/tags.
|
||||||
|
func (h *HTMLCleaner) CleanAndTrim(htmlStr string) string {
|
||||||
|
return strings.TrimSpace(h.CleanHTML(htmlStr))
|
||||||
|
}
|
||||||
|
|
||||||
// extractText recursively traverses the HTML node tree and extracts text content.
|
// extractText recursively traverses the HTML node tree and extracts text content.
|
||||||
// It skips script and style tags to avoid including their content in the output.
|
// It skips script and style tags to avoid including their content in the output.
|
||||||
func extractText(w io.Writer, n *html.Node) {
|
func extractText(w io.Writer, n *html.Node) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/kjanat/articulate-parser/internal/config"
|
||||||
|
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewLoggerFromConfig creates a logger based on the configuration.
|
||||||
|
// Returns a JSON logger if cfg.LogFormat is "json", otherwise returns a text logger.
|
||||||
|
func NewLoggerFromConfig(cfg *config.Config) interfaces.Logger {
|
||||||
|
if cfg.LogFormat == "json" {
|
||||||
|
return NewSlogLogger(cfg.LogLevel)
|
||||||
|
}
|
||||||
|
return NewTextLogger(cfg.LogLevel)
|
||||||
|
}
|
||||||
+24
-22
@@ -9,12 +9,19 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/apperrors"
|
||||||
|
"github.com/kjanat/articulate-parser/internal/config"
|
||||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||||
"github.com/kjanat/articulate-parser/internal/models"
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// riseHost is the only host accepted in Articulate Rise share URIs.
|
||||||
|
const riseHost = "rise.articulate.com"
|
||||||
|
|
||||||
|
// shareIDRegex is compiled once at package init for extracting share IDs from URIs.
|
||||||
|
var shareIDRegex = regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`)
|
||||||
|
|
||||||
// ArticulateParser implements the CourseParser interface specifically for Articulate Rise courses.
|
// ArticulateParser implements the CourseParser interface specifically for Articulate Rise courses.
|
||||||
// It can fetch courses from the Articulate Rise API or load them from local JSON files.
|
// It can fetch courses from the Articulate Rise API or load them from local JSON files.
|
||||||
type ArticulateParser struct {
|
type ArticulateParser struct {
|
||||||
@@ -26,23 +33,19 @@ type ArticulateParser struct {
|
|||||||
Logger interfaces.Logger
|
Logger interfaces.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArticulateParser creates a new ArticulateParser instance.
|
// NewArticulateParser creates a new ArticulateParser instance using the provided configuration.
|
||||||
// If baseURL is empty, uses the default Articulate Rise API URL.
|
// If cfg is nil, a default configuration will be used.
|
||||||
// If timeout is zero, uses a 30-second timeout.
|
func NewArticulateParser(logger interfaces.Logger, cfg *config.Config) interfaces.CourseParser {
|
||||||
func NewArticulateParser(logger interfaces.Logger, baseURL string, timeout time.Duration) interfaces.CourseParser {
|
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = NewNoOpLogger()
|
logger = NewNoOpLogger()
|
||||||
}
|
}
|
||||||
if baseURL == "" {
|
if cfg == nil {
|
||||||
baseURL = "https://rise.articulate.com"
|
cfg = config.Load()
|
||||||
}
|
|
||||||
if timeout == 0 {
|
|
||||||
timeout = 30 * time.Second
|
|
||||||
}
|
}
|
||||||
return &ArticulateParser{
|
return &ArticulateParser{
|
||||||
BaseURL: baseURL,
|
BaseURL: cfg.BaseURL,
|
||||||
Client: &http.Client{
|
Client: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: cfg.RequestTimeout,
|
||||||
},
|
},
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
}
|
}
|
||||||
@@ -78,15 +81,15 @@ func (p *ArticulateParser) FetchCourse(ctx context.Context, uri string) (*models
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
var course models.Course
|
var course models.Course
|
||||||
if err := json.Unmarshal(body, &course); err != nil {
|
if err := json.Unmarshal(body, &course); err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
|
||||||
@@ -125,18 +128,17 @@ func (p *ArticulateParser) extractShareID(uri string) (string, error) {
|
|||||||
// Parse the URL to validate the domain
|
// Parse the URL to validate the domain
|
||||||
parsedURL, err := url.Parse(uri)
|
parsedURL, err := url.Parse(uri)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid URI: %s", uri)
|
return "", fmt.Errorf("%w: %s", apperrors.ErrInvalidURI, uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that it's an Articulate Rise domain
|
// Validate that it's an Articulate Rise domain
|
||||||
if parsedURL.Host != "rise.articulate.com" {
|
if parsedURL.Host != riseHost {
|
||||||
return "", fmt.Errorf("invalid domain for Articulate Rise URI: %s", parsedURL.Host)
|
return "", fmt.Errorf("%w: %s", apperrors.ErrInvalidDomain, parsedURL.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
re := regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`)
|
matches := shareIDRegex.FindStringSubmatch(uri)
|
||||||
matches := re.FindStringSubmatch(uri)
|
|
||||||
if len(matches) < 2 {
|
if len(matches) < 2 {
|
||||||
return "", fmt.Errorf("could not extract share ID from URI: %s", uri)
|
return "", fmt.Errorf("%w: %s", apperrors.ErrNoShareID, uri)
|
||||||
}
|
}
|
||||||
return matches[1], nil
|
return matches[1], nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/config"
|
||||||
"github.com/kjanat/articulate-parser/internal/models"
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,7 +135,11 @@ func BenchmarkArticulateParser_LoadCourseFromFile(b *testing.B) {
|
|||||||
b.Fatalf("Failed to write file: %v", err)
|
b.Fatalf("Failed to write file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
parser := NewArticulateParser(nil, "", 0)
|
cfg := &config.Config{
|
||||||
|
BaseURL: "https://rise.articulate.com",
|
||||||
|
RequestTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
parser := NewArticulateParser(nil, cfg)
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for b.Loop() {
|
for b.Loop() {
|
||||||
@@ -180,7 +186,11 @@ func BenchmarkArticulateParser_LoadCourseFromFile_Large(b *testing.B) {
|
|||||||
b.Fatalf("Failed to write file: %v", err)
|
b.Fatalf("Failed to write file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
parser := NewArticulateParser(nil, "", 0)
|
cfg := &config.Config{
|
||||||
|
BaseURL: "https://rise.articulate.com",
|
||||||
|
RequestTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
parser := NewArticulateParser(nil, cfg)
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for b.Loop() {
|
for b.Loop() {
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FuzzArticulateParser_ParseJSON fuzzes JSON parsing to ensure no panics on malformed input.
|
||||||
|
// It tests that the parser gracefully handles any byte sequence without crashing.
|
||||||
|
func FuzzArticulateParser_ParseJSON(f *testing.F) {
|
||||||
|
// Add seed corpus with valid JSON structures
|
||||||
|
f.Add([]byte(`{"shareId":"test","course":{"id":"1","title":"Test"}}`))
|
||||||
|
f.Add([]byte(`{"shareId":"abc123","author":"John Doe","course":{"id":"c1","title":"Course Title","description":"A test course"}}`))
|
||||||
|
f.Add([]byte(`{}`))
|
||||||
|
f.Add([]byte(`{"course":{}}`))
|
||||||
|
f.Add([]byte(`{"shareId":"","course":{"id":"","title":"","lessons":[]}}`))
|
||||||
|
|
||||||
|
// Add seed corpus with invalid/edge case inputs
|
||||||
|
f.Add([]byte(`invalid`))
|
||||||
|
f.Add([]byte(``))
|
||||||
|
f.Add([]byte(`null`))
|
||||||
|
f.Add([]byte(`[]`))
|
||||||
|
f.Add([]byte(`"string"`))
|
||||||
|
f.Add([]byte(`123`))
|
||||||
|
f.Add([]byte(`true`))
|
||||||
|
f.Add([]byte(`{`))
|
||||||
|
f.Add([]byte(`}`))
|
||||||
|
f.Add([]byte(`{"unclosed": `))
|
||||||
|
f.Add([]byte(`{"key": "value with \x00 null byte"}`))
|
||||||
|
|
||||||
|
// Add seed corpus with nested structures
|
||||||
|
f.Add([]byte(`{"course":{"lessons":[{"id":"l1","title":"Lesson 1","items":[{"id":"i1","type":"text","data":"content"}]}]}}`))
|
||||||
|
f.Add([]byte(`{"course":{"lessons":[{},{},{}]}}`))
|
||||||
|
|
||||||
|
// Add seed corpus with special characters
|
||||||
|
f.Add([]byte(`{"shareId":"test<script>alert('xss')</script>"}`))
|
||||||
|
f.Add([]byte(`{"course":{"title":"Test\n\r\t\u0000Title"}}`))
|
||||||
|
f.Add([]byte(`{"course":{"description":"<p>HTML content</p>"}}`))
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
// Test that unmarshaling doesn't panic
|
||||||
|
var course models.Course
|
||||||
|
_ = json.Unmarshal(data, &course)
|
||||||
|
|
||||||
|
// Also test that re-marshaling doesn't panic
|
||||||
|
_, _ = json.Marshal(&course)
|
||||||
|
|
||||||
|
// Test accessing fields doesn't panic
|
||||||
|
_ = course.ShareID
|
||||||
|
_ = course.Author
|
||||||
|
_ = course.Course.ID
|
||||||
|
_ = course.Course.Title
|
||||||
|
_ = course.Course.Description
|
||||||
|
|
||||||
|
// Test iterating over lessons doesn't panic
|
||||||
|
for _, lesson := range course.Course.Lessons {
|
||||||
|
_ = lesson.ID
|
||||||
|
_ = lesson.Title
|
||||||
|
for _, item := range lesson.Items {
|
||||||
|
_ = item.ID
|
||||||
|
_ = item.Type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzArticulateParser_LoadCourseFromFile_JSON tests JSON parsing behavior used by LoadCourseFromFile.
|
||||||
|
// This fuzz test ensures the JSON unmarshaling logic handles arbitrary input gracefully.
|
||||||
|
func FuzzArticulateParser_LoadCourseFromFile_JSON(f *testing.F) {
|
||||||
|
// Seed corpus mimicking file contents
|
||||||
|
f.Add([]byte(`{"shareId":"file-test","course":{"id":"1","title":"From File"}}`))
|
||||||
|
f.Add([]byte(`{
|
||||||
|
"shareId": "multiline",
|
||||||
|
"course": {
|
||||||
|
"id": "2",
|
||||||
|
"title": "Multiline JSON"
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
f.Add([]byte("\xef\xbb\xbf{\"shareId\":\"bom\"}")) // UTF-8 BOM prefix
|
||||||
|
f.Add([]byte(`{"shareId": "spaces", "course": {}}`))
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
var course models.Course
|
||||||
|
err := json.Unmarshal(data, &course)
|
||||||
|
|
||||||
|
// If unmarshaling succeeded, verify the result is usable
|
||||||
|
if err == nil {
|
||||||
|
// Access all fields to ensure no panics on zero values
|
||||||
|
_ = course.ShareID
|
||||||
|
_ = course.Author
|
||||||
|
_ = course.Course.ID
|
||||||
|
_ = course.Course.Title
|
||||||
|
|
||||||
|
// Iterate through nested structures
|
||||||
|
for _, lesson := range course.Course.Lessons {
|
||||||
|
_ = lesson.ID
|
||||||
|
_ = lesson.Title
|
||||||
|
_ = lesson.Type
|
||||||
|
for _, item := range lesson.Items {
|
||||||
|
_ = item.ID
|
||||||
|
_ = item.Type
|
||||||
|
_ = item.Data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzHTMLCleaner_CleanHTML fuzzes the HTML cleaner to ensure no panics on malformed HTML.
|
||||||
|
// It tests that arbitrary HTML-like input is handled gracefully.
|
||||||
|
func FuzzHTMLCleaner_CleanHTML(f *testing.F) {
|
||||||
|
cleaner := NewHTMLCleaner()
|
||||||
|
|
||||||
|
// Add seed corpus with valid HTML patterns
|
||||||
|
f.Add("<p>Simple paragraph</p>")
|
||||||
|
f.Add("<div><p>Nested</p></div>")
|
||||||
|
f.Add("<h1>Title</h1><p>Content</p>")
|
||||||
|
f.Add("<strong>Bold</strong> and <em>italic</em>")
|
||||||
|
f.Add("<a href=\"https://example.com\">Link</a>")
|
||||||
|
f.Add("<img src=\"image.png\" alt=\"Image\"/>")
|
||||||
|
f.Add("<br/><hr/>")
|
||||||
|
f.Add("<ul><li>Item 1</li><li>Item 2</li></ul>")
|
||||||
|
f.Add("<table><tr><td>Cell</td></tr></table>")
|
||||||
|
|
||||||
|
// Add seed corpus with HTML entities
|
||||||
|
f.Add("AT&T")
|
||||||
|
f.Add("<script>alert('xss')</script>")
|
||||||
|
f.Add(" ")
|
||||||
|
f.Add("'apostrophe'")
|
||||||
|
f.Add(""quoted"")
|
||||||
|
f.Add("éïö")
|
||||||
|
f.Add("'hex entity'")
|
||||||
|
f.Add("’numeric entity’")
|
||||||
|
|
||||||
|
// Add seed corpus with malformed/edge case HTML
|
||||||
|
f.Add("")
|
||||||
|
f.Add("plain text no HTML")
|
||||||
|
f.Add("<p>Unclosed paragraph")
|
||||||
|
f.Add("</p>Close tag first<p>")
|
||||||
|
f.Add("<div><div><div>Deep nesting")
|
||||||
|
f.Add("<script>alert('xss')</script>")
|
||||||
|
f.Add("<style>body{color:red}</style>")
|
||||||
|
f.Add("<!--comment-->")
|
||||||
|
f.Add("<!DOCTYPE html>")
|
||||||
|
f.Add("<![CDATA[data]]>")
|
||||||
|
f.Add("<p class=\"test\" id='test' data-attr=unquoted>Attributes</p>")
|
||||||
|
f.Add("<p\n\t>Whitespace in tag</p\n>")
|
||||||
|
f.Add("<>empty tag</>")
|
||||||
|
f.Add("<<<nested angles>>>")
|
||||||
|
f.Add("<p onclick=\"evil()\">Event handler</p>")
|
||||||
|
|
||||||
|
// Add seed corpus with special characters
|
||||||
|
f.Add("<p>\x00null byte</p>")
|
||||||
|
f.Add("<p>\xffhigh byte</p>")
|
||||||
|
f.Add("<p>emoji: \xf0\x9f\x98\x80</p>")
|
||||||
|
f.Add("<p>unicode: \u2603</p>")
|
||||||
|
f.Add("<p>newlines\n\r\nand\rtabs\there</p>")
|
||||||
|
|
||||||
|
// Add seed corpus with potentially problematic patterns
|
||||||
|
f.Add(string(make([]byte, 0))) // Empty
|
||||||
|
f.Add(string(make([]byte, 1))) // Single null byte
|
||||||
|
f.Add("<" + string(make([]byte, 100)) + ">") // Tag with null bytes
|
||||||
|
f.Add("<p>" + string(make([]byte, 1000)) + "</p>") // Large content
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, input string) {
|
||||||
|
// Test that CleanHTML doesn't panic on any input
|
||||||
|
result := cleaner.CleanHTML(input)
|
||||||
|
|
||||||
|
// Verify the result is a valid string (doesn't panic on access)
|
||||||
|
_ = len(result)
|
||||||
|
|
||||||
|
// Test that the result can be used in common string operations without panic
|
||||||
|
_ = result == ""
|
||||||
|
_ = result == input
|
||||||
|
|
||||||
|
// Test that calling CleanHTML on its own output doesn't panic (idempotency check)
|
||||||
|
_ = cleaner.CleanHTML(result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzHTMLCleaner_CleanHTML_Bytes tests HTML cleaner with raw byte sequences.
|
||||||
|
// This catches issues with invalid UTF-8 and binary data in HTML content.
|
||||||
|
func FuzzHTMLCleaner_CleanHTML_Bytes(f *testing.F) {
|
||||||
|
cleaner := NewHTMLCleaner()
|
||||||
|
|
||||||
|
// Add seed corpus with byte sequences
|
||||||
|
f.Add([]byte("<p>normal</p>"))
|
||||||
|
f.Add([]byte{0x3c, 0x70, 0x3e, 0xff, 0xfe, 0x3c, 0x2f, 0x70, 0x3e}) // <p>..invalid..</p>
|
||||||
|
f.Add([]byte{0x00}) // Null byte
|
||||||
|
f.Add([]byte{0xff, 0xfe}) // Invalid UTF-8
|
||||||
|
f.Add([]byte{0xef, 0xbb, 0xbf, 0x3c, 0x70, 0x3e, 0x3c, 0x2f, 0x70, 0x3e}) // UTF-8 BOM + <p></p>
|
||||||
|
f.Add([]byte("<p>\xc0\xaf</p>")) // Overlong UTF-8
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
// Convert bytes to string and test
|
||||||
|
input := string(data)
|
||||||
|
|
||||||
|
// Test that CleanHTML doesn't panic
|
||||||
|
result := cleaner.CleanHTML(input)
|
||||||
|
|
||||||
|
// Verify result is usable
|
||||||
|
_ = len(result)
|
||||||
|
_ = []byte(result)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,12 +11,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/config"
|
||||||
"github.com/kjanat/articulate-parser/internal/models"
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNewArticulateParser tests the NewArticulateParser constructor.
|
// TestNewArticulateParser tests the NewArticulateParser constructor.
|
||||||
func TestNewArticulateParser(t *testing.T) {
|
func TestNewArticulateParser(t *testing.T) {
|
||||||
parser := NewArticulateParser(nil, "", 0)
|
// Test with nil config (should use defaults)
|
||||||
|
parser := NewArticulateParser(nil, nil)
|
||||||
|
|
||||||
if parser == nil {
|
if parser == nil {
|
||||||
t.Fatal("NewArticulateParser() returned nil")
|
t.Fatal("NewArticulateParser() returned nil")
|
||||||
@@ -212,7 +214,11 @@ func TestArticulateParser_LoadCourseFromFile(t *testing.T) {
|
|||||||
t.Fatalf("Failed to write test file: %v", err)
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
parser := NewArticulateParser(nil, "", 0)
|
cfg := &config.Config{
|
||||||
|
BaseURL: "https://rise.articulate.com",
|
||||||
|
RequestTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
parser := NewArticulateParser(nil, cfg)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -271,7 +277,11 @@ func TestArticulateParser_LoadCourseFromFile_InvalidJSON(t *testing.T) {
|
|||||||
t.Fatalf("Failed to write test file: %v", err)
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
parser := NewArticulateParser(nil, "", 0)
|
cfg := &config.Config{
|
||||||
|
BaseURL: "https://rise.articulate.com",
|
||||||
|
RequestTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
parser := NewArticulateParser(nil, cfg)
|
||||||
_, err := parser.LoadCourseFromFile(tempFile)
|
_, err := parser.LoadCourseFromFile(tempFile)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
Vendored
+467
@@ -0,0 +1,467 @@
|
|||||||
|
// Package testdata provides shared test fixtures for Articulate Rise course testing.
|
||||||
|
// It contains builders for creating Course, Lesson, and Item structures with various
|
||||||
|
// configurations to support unit tests, integration tests, and benchmarks.
|
||||||
|
package testdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SampleAnswers provides a reusable set of quiz answers for testing.
|
||||||
|
var SampleAnswers = []models.Answer{
|
||||||
|
{ID: "ans-1", Title: "Option A", Correct: false},
|
||||||
|
{ID: "ans-2", Title: "Option B", Correct: true},
|
||||||
|
{ID: "ans-3", Title: "Option C", Correct: false},
|
||||||
|
{ID: "ans-4", Title: "Option D", Correct: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
// SampleSubItems provides a reusable set of sub-items for testing.
|
||||||
|
var SampleSubItems = []models.SubItem{
|
||||||
|
{
|
||||||
|
ID: "sub-1",
|
||||||
|
Heading: "Sample Heading",
|
||||||
|
Paragraph: "<p>Sample paragraph with <strong>formatting</strong>.</p>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "sub-2",
|
||||||
|
Paragraph: "<p>Another paragraph with <em>emphasis</em>.</p>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinimalCourse returns a course with just ShareID and Title set.
|
||||||
|
// Useful for testing edge cases with minimal data.
|
||||||
|
func MinimalCourse() *models.Course {
|
||||||
|
return &models.Course{
|
||||||
|
ShareID: "minimal-share-id",
|
||||||
|
Course: models.CourseInfo{
|
||||||
|
Title: "Minimal Course",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasicCourse returns a course with 1 section, 1 lesson, and 1 text item.
|
||||||
|
// Suitable for basic functionality tests.
|
||||||
|
func BasicCourse() *models.Course {
|
||||||
|
return &models.Course{
|
||||||
|
ShareID: "basic-share-id",
|
||||||
|
Author: "Test Author",
|
||||||
|
Course: models.CourseInfo{
|
||||||
|
ID: "basic-course-id",
|
||||||
|
Title: "Basic Test Course",
|
||||||
|
Description: "<p>A basic course for testing.</p>",
|
||||||
|
NavigationMode: "menu",
|
||||||
|
Lessons: []models.Lesson{
|
||||||
|
{
|
||||||
|
ID: "section-1",
|
||||||
|
Title: "Introduction Section",
|
||||||
|
Type: "section",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "lesson-1",
|
||||||
|
Title: "First Lesson",
|
||||||
|
Type: "lesson",
|
||||||
|
Description: "<p>Lesson introduction text.</p>",
|
||||||
|
Items: []models.Item{
|
||||||
|
TextItem("Welcome", "<p>Welcome to the course!</p>"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteCourse returns a course with all item types represented.
|
||||||
|
// Useful for comprehensive export and parsing tests.
|
||||||
|
func CompleteCourse() *models.Course {
|
||||||
|
return &models.Course{
|
||||||
|
ShareID: "complete-share-id",
|
||||||
|
Author: "Complete Test Author",
|
||||||
|
Course: models.CourseInfo{
|
||||||
|
ID: "complete-course-id",
|
||||||
|
Title: "Complete Test Course",
|
||||||
|
Description: "<p>A comprehensive course with <strong>all</strong> item types.</p>",
|
||||||
|
Color: "#3366CC",
|
||||||
|
NavigationMode: "menu",
|
||||||
|
ExportSettings: &models.ExportSettings{
|
||||||
|
Title: "Exported Complete Course",
|
||||||
|
Format: "scorm",
|
||||||
|
},
|
||||||
|
CoverImage: &models.Media{
|
||||||
|
Image: &models.ImageMedia{
|
||||||
|
Key: "cover-img-key",
|
||||||
|
Type: "jpg",
|
||||||
|
Width: 1920,
|
||||||
|
Height: 1080,
|
||||||
|
OriginalURL: "https://example.com/cover.jpg",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Lessons: []models.Lesson{
|
||||||
|
{
|
||||||
|
ID: "section-1",
|
||||||
|
Title: "Course Overview",
|
||||||
|
Type: "section",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "lesson-1",
|
||||||
|
Title: "Introduction",
|
||||||
|
Type: "lesson",
|
||||||
|
Description: "<p>Introduction to the course content.</p>",
|
||||||
|
Icon: "book",
|
||||||
|
Items: []models.Item{
|
||||||
|
TextItem("Welcome", "<p>Welcome to this comprehensive course!</p>"),
|
||||||
|
TextItem("Objectives", "<p>In this course you will learn:</p>"),
|
||||||
|
ListItem("First objective", "Second objective", "Third objective"),
|
||||||
|
DividerItem(),
|
||||||
|
MediaItem("video", "https://example.com/intro.mp4"),
|
||||||
|
MediaItem("image", "https://example.com/diagram.png"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "lesson-2",
|
||||||
|
Title: "Core Concepts",
|
||||||
|
Type: "lesson",
|
||||||
|
Description: "<p>Core concepts and fundamentals.</p>",
|
||||||
|
Items: []models.Item{
|
||||||
|
TextItem("Key Concepts", "<p>Let's explore the key concepts.</p>"),
|
||||||
|
KnowledgeCheckItem("What is the primary purpose of this course?", []models.Answer{
|
||||||
|
{ID: "kc-1-a", Title: "Learning basics", Correct: false},
|
||||||
|
{ID: "kc-1-b", Title: "Comprehensive understanding", Correct: true},
|
||||||
|
{ID: "kc-1-c", Title: "Quick overview", Correct: false},
|
||||||
|
}),
|
||||||
|
InteractiveItem("Interactive Exercise"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "lesson-3",
|
||||||
|
Title: "Advanced Topics",
|
||||||
|
Type: "lesson",
|
||||||
|
Description: "<p>Deep dive into advanced topics.</p>",
|
||||||
|
Items: []models.Item{
|
||||||
|
TextItem("Advanced Section", "<p>This section covers advanced material.</p>"),
|
||||||
|
FlipCardItem("Front content", "Back content"),
|
||||||
|
QuoteItem("Learning is not attained by chance.", "Abigail Adams"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "section-2",
|
||||||
|
Title: "Summary",
|
||||||
|
Type: "section",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "lesson-4",
|
||||||
|
Title: "Conclusion",
|
||||||
|
Type: "lesson",
|
||||||
|
Description: "<p>Course wrap-up and next steps.</p>",
|
||||||
|
Items: []models.Item{
|
||||||
|
TextItem("Summary", "<p>Thank you for completing this course!</p>"),
|
||||||
|
ListItem("Review materials regularly", "Practice learned skills", "Share knowledge"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
LabelSet: models.LabelSet{
|
||||||
|
ID: "default-labels",
|
||||||
|
Name: "Default",
|
||||||
|
Labels: map[string]string{
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"complete": "Mark Complete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LargeCourse returns a parameterized course for benchmarking with the specified number of lessons.
|
||||||
|
// Each lesson contains multiple item types for realistic performance testing.
|
||||||
|
func LargeCourse(lessons int) *models.Course {
|
||||||
|
courseLessons := make([]models.Lesson, 0, lessons+lessons/5) // +sections
|
||||||
|
|
||||||
|
for i := range lessons {
|
||||||
|
// Add section header every 5 lessons
|
||||||
|
if i%5 == 0 {
|
||||||
|
courseLessons = append(courseLessons, models.Lesson{
|
||||||
|
ID: fmt.Sprintf("section-%d", i/5+1),
|
||||||
|
Title: fmt.Sprintf("Section %d", i/5+1),
|
||||||
|
Type: "section",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
lesson := models.Lesson{
|
||||||
|
ID: fmt.Sprintf("lesson-%d", i+1),
|
||||||
|
Title: fmt.Sprintf("Lesson %d", i+1),
|
||||||
|
Type: "lesson",
|
||||||
|
Description: fmt.Sprintf("<p>Description for lesson %d with <em>formatting</em>.</p>", i+1),
|
||||||
|
Items: []models.Item{
|
||||||
|
TextItem(
|
||||||
|
fmt.Sprintf("Heading %d", i+1),
|
||||||
|
fmt.Sprintf("<p>Content for lesson %d with <strong>bold</strong> and <em>italic</em> text.</p>", i+1),
|
||||||
|
),
|
||||||
|
ListItem("Point 1", "Point 2", "Point 3"),
|
||||||
|
KnowledgeCheckItem(
|
||||||
|
fmt.Sprintf("Quiz Question %d", i+1),
|
||||||
|
[]models.Answer{
|
||||||
|
{ID: fmt.Sprintf("q%d-a", i), Title: "Answer A", Correct: false},
|
||||||
|
{ID: fmt.Sprintf("q%d-b", i), Title: "Answer B", Correct: true},
|
||||||
|
{ID: fmt.Sprintf("q%d-c", i), Title: "Answer C", Correct: false},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
courseLessons = append(courseLessons, lesson)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.Course{
|
||||||
|
ShareID: "large-benchmark-id",
|
||||||
|
Author: "Benchmark Author",
|
||||||
|
Course: models.CourseInfo{
|
||||||
|
ID: "large-benchmark-course",
|
||||||
|
Title: fmt.Sprintf("Large Benchmark Course (%d lessons)", lessons),
|
||||||
|
Description: "<p>Large course for performance testing.</p>",
|
||||||
|
NavigationMode: "menu",
|
||||||
|
Lessons: courseLessons,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextItem creates a text item with the specified heading and paragraph content.
|
||||||
|
func TextItem(heading, paragraph string) models.Item {
|
||||||
|
return models.Item{
|
||||||
|
Type: "text",
|
||||||
|
Family: "text",
|
||||||
|
Items: []models.SubItem{
|
||||||
|
{
|
||||||
|
ID: "text-sub-1",
|
||||||
|
Heading: heading,
|
||||||
|
Paragraph: paragraph,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListItem creates a list item with the specified items as list entries.
|
||||||
|
func ListItem(items ...string) models.Item {
|
||||||
|
subItems := make([]models.SubItem, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
subItems[i] = models.SubItem{
|
||||||
|
ID: fmt.Sprintf("list-sub-%d", i+1),
|
||||||
|
Paragraph: fmt.Sprintf("<p>%s</p>", item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.Item{
|
||||||
|
Type: "list",
|
||||||
|
Family: "list",
|
||||||
|
Items: subItems,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KnowledgeCheckItem creates a knowledge check item with the specified question and answers.
|
||||||
|
func KnowledgeCheckItem(question string, answers []models.Answer) models.Item {
|
||||||
|
return models.Item{
|
||||||
|
Type: "knowledgeCheck",
|
||||||
|
Family: "knowledge",
|
||||||
|
Items: []models.SubItem{
|
||||||
|
{
|
||||||
|
ID: "kc-sub-1",
|
||||||
|
Title: fmt.Sprintf("<p>%s</p>", question),
|
||||||
|
Answers: answers,
|
||||||
|
Feedback: "<p>Thank you for your response.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaItem creates a media item (video or image) with the specified type and URL.
|
||||||
|
// The mediaType should be "video" or "image".
|
||||||
|
func MediaItem(mediaType, url string) models.Item {
|
||||||
|
var media *models.Media
|
||||||
|
|
||||||
|
switch mediaType {
|
||||||
|
case "video":
|
||||||
|
media = &models.Media{
|
||||||
|
Video: &models.VideoMedia{
|
||||||
|
Key: "video-key",
|
||||||
|
URL: url,
|
||||||
|
Type: "mp4",
|
||||||
|
Duration: 120,
|
||||||
|
OriginalURL: url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "image":
|
||||||
|
media = &models.Media{
|
||||||
|
Image: &models.ImageMedia{
|
||||||
|
Key: "image-key",
|
||||||
|
Type: "png",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
OriginalURL: url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
media = &models.Media{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.Item{
|
||||||
|
Type: "multimedia",
|
||||||
|
Family: "media",
|
||||||
|
Items: []models.SubItem{
|
||||||
|
{
|
||||||
|
ID: "media-sub-1",
|
||||||
|
Title: fmt.Sprintf("<p>%s content</p>", mediaType),
|
||||||
|
Caption: fmt.Sprintf("<p>Caption for %s</p>", mediaType),
|
||||||
|
Media: media,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InteractiveItem creates an interactive content item with the specified title.
|
||||||
|
func InteractiveItem(title string) models.Item {
|
||||||
|
return models.Item{
|
||||||
|
Type: "interactive",
|
||||||
|
Family: "interactive",
|
||||||
|
Items: []models.SubItem{
|
||||||
|
{
|
||||||
|
ID: "interactive-sub-1",
|
||||||
|
Title: fmt.Sprintf("<p>%s</p>", title),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DividerItem creates a divider/separator item.
|
||||||
|
func DividerItem() models.Item {
|
||||||
|
return models.Item{
|
||||||
|
Type: "divider",
|
||||||
|
Family: "divider",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlipCardItem creates a flashcard-style item with front and back content.
|
||||||
|
func FlipCardItem(front, back string) models.Item {
|
||||||
|
return models.Item{
|
||||||
|
Type: "flashcard",
|
||||||
|
Family: "interactive",
|
||||||
|
Items: []models.SubItem{
|
||||||
|
{
|
||||||
|
ID: "flipcard-sub-1",
|
||||||
|
Front: &models.CardSide{
|
||||||
|
Description: fmt.Sprintf("<p>%s</p>", front),
|
||||||
|
},
|
||||||
|
Back: &models.CardSide{
|
||||||
|
Description: fmt.Sprintf("<p>%s</p>", back),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuoteItem creates a quote/blockquote item with text and attribution.
|
||||||
|
func QuoteItem(quote, author string) models.Item {
|
||||||
|
return models.Item{
|
||||||
|
Type: "quote",
|
||||||
|
Family: "text",
|
||||||
|
Items: []models.SubItem{
|
||||||
|
{
|
||||||
|
ID: "quote-sub-1",
|
||||||
|
Paragraph: fmt.Sprintf("<blockquote>%s</blockquote>", quote),
|
||||||
|
Title: author,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageItem creates a standalone image item with caption.
|
||||||
|
func ImageItem(url, caption string) models.Item {
|
||||||
|
return models.Item{
|
||||||
|
Type: "image",
|
||||||
|
Family: "media",
|
||||||
|
Items: []models.SubItem{
|
||||||
|
{
|
||||||
|
ID: "image-sub-1",
|
||||||
|
Caption: fmt.Sprintf("<p>%s</p>", caption),
|
||||||
|
Media: &models.Media{
|
||||||
|
Image: &models.ImageMedia{
|
||||||
|
Key: "standalone-img-key",
|
||||||
|
Type: "jpg",
|
||||||
|
Width: 1200,
|
||||||
|
Height: 800,
|
||||||
|
OriginalURL: url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VideoItem creates a standalone video item with caption.
|
||||||
|
func VideoItem(url string, duration int, caption string) models.Item {
|
||||||
|
return models.Item{
|
||||||
|
Type: "video",
|
||||||
|
Family: "media",
|
||||||
|
Items: []models.SubItem{
|
||||||
|
{
|
||||||
|
ID: "video-sub-1",
|
||||||
|
Caption: fmt.Sprintf("<p>%s</p>", caption),
|
||||||
|
Media: &models.Media{
|
||||||
|
Video: &models.VideoMedia{
|
||||||
|
Key: "standalone-video-key",
|
||||||
|
URL: url,
|
||||||
|
Type: "mp4",
|
||||||
|
Duration: duration,
|
||||||
|
OriginalURL: url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchingItem creates a matching/pairing knowledge check item.
|
||||||
|
func MatchingItem(pairs map[string]string) models.Item {
|
||||||
|
answers := make([]models.Answer, 0, len(pairs))
|
||||||
|
i := 0
|
||||||
|
for term, match := range pairs {
|
||||||
|
answers = append(answers, models.Answer{
|
||||||
|
ID: fmt.Sprintf("match-%d", i),
|
||||||
|
Title: term,
|
||||||
|
MatchTitle: match,
|
||||||
|
Correct: true,
|
||||||
|
})
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.Item{
|
||||||
|
Type: "matching",
|
||||||
|
Family: "knowledge",
|
||||||
|
Items: []models.SubItem{
|
||||||
|
{
|
||||||
|
ID: "matching-sub-1",
|
||||||
|
Title: "<p>Match the following items:</p>",
|
||||||
|
Answers: answers,
|
||||||
|
Feedback: "<p>Check your matches!</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmptyLesson returns a lesson with no items for edge case testing.
|
||||||
|
func EmptyLesson(id, title string) models.Lesson {
|
||||||
|
return models.Lesson{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Type: "lesson",
|
||||||
|
Items: []models.Item{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SectionLesson returns a section header (not a content lesson).
|
||||||
|
func SectionLesson(id, title string) models.Lesson {
|
||||||
|
return models.Lesson{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Type: "section",
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+273
@@ -0,0 +1,273 @@
|
|||||||
|
package testdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMinimalCourse(t *testing.T) {
|
||||||
|
course := MinimalCourse()
|
||||||
|
if course == nil {
|
||||||
|
t.Fatal("MinimalCourse() returned nil")
|
||||||
|
}
|
||||||
|
if course.ShareID == "" {
|
||||||
|
t.Error("MinimalCourse() should have ShareID set")
|
||||||
|
}
|
||||||
|
if course.Course.Title == "" {
|
||||||
|
t.Error("MinimalCourse() should have Title set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicCourse(t *testing.T) {
|
||||||
|
course := BasicCourse()
|
||||||
|
if course == nil {
|
||||||
|
t.Fatal("BasicCourse() returned nil")
|
||||||
|
}
|
||||||
|
if len(course.Course.Lessons) != 2 {
|
||||||
|
t.Errorf("BasicCourse() should have 2 lessons, got %d", len(course.Course.Lessons))
|
||||||
|
}
|
||||||
|
if course.Course.Lessons[0].Type != "section" {
|
||||||
|
t.Error("First lesson should be a section")
|
||||||
|
}
|
||||||
|
if course.Course.Lessons[1].Type != "lesson" {
|
||||||
|
t.Error("Second lesson should be a lesson")
|
||||||
|
}
|
||||||
|
if len(course.Course.Lessons[1].Items) != 1 {
|
||||||
|
t.Errorf("Lesson should have 1 item, got %d", len(course.Course.Lessons[1].Items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteCourse(t *testing.T) {
|
||||||
|
course := CompleteCourse()
|
||||||
|
if course == nil {
|
||||||
|
t.Fatal("CompleteCourse() returned nil")
|
||||||
|
}
|
||||||
|
if len(course.Course.Lessons) == 0 {
|
||||||
|
t.Error("CompleteCourse() should have lessons")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all expected item types exist
|
||||||
|
itemTypes := make(map[string]bool)
|
||||||
|
for _, lesson := range course.Course.Lessons {
|
||||||
|
for _, item := range lesson.Items {
|
||||||
|
itemTypes[item.Type] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedTypes := []string{"text", "list", "divider", "multimedia", "knowledgeCheck", "interactive", "flashcard", "quote"}
|
||||||
|
for _, et := range expectedTypes {
|
||||||
|
if !itemTypes[et] {
|
||||||
|
t.Errorf("CompleteCourse() should have item type %q", et)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLargeCourse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
lessons int
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{1, 2}, // 1 lesson + 1 section
|
||||||
|
{5, 6}, // 5 lessons + 1 section
|
||||||
|
{10, 12}, // 10 lessons + 2 sections
|
||||||
|
{50, 60}, // 50 lessons + 10 sections
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
course := LargeCourse(tt.lessons)
|
||||||
|
if course == nil {
|
||||||
|
t.Fatalf("LargeCourse(%d) returned nil", tt.lessons)
|
||||||
|
}
|
||||||
|
if len(course.Course.Lessons) != tt.expected {
|
||||||
|
t.Errorf("LargeCourse(%d) should have %d lessons+sections, got %d",
|
||||||
|
tt.lessons, tt.expected, len(course.Course.Lessons))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextItem(t *testing.T) {
|
||||||
|
item := TextItem("Test Heading", "<p>Test paragraph</p>")
|
||||||
|
if item.Type != "text" {
|
||||||
|
t.Errorf("TextItem Type should be 'text', got %q", item.Type)
|
||||||
|
}
|
||||||
|
if len(item.Items) != 1 {
|
||||||
|
t.Fatalf("TextItem should have 1 sub-item, got %d", len(item.Items))
|
||||||
|
}
|
||||||
|
if item.Items[0].Heading != "Test Heading" {
|
||||||
|
t.Errorf("TextItem heading mismatch: got %q", item.Items[0].Heading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListItem(t *testing.T) {
|
||||||
|
item := ListItem("Item 1", "Item 2", "Item 3")
|
||||||
|
if item.Type != "list" {
|
||||||
|
t.Errorf("ListItem Type should be 'list', got %q", item.Type)
|
||||||
|
}
|
||||||
|
if len(item.Items) != 3 {
|
||||||
|
t.Errorf("ListItem should have 3 sub-items, got %d", len(item.Items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnowledgeCheckItem(t *testing.T) {
|
||||||
|
answers := []models.Answer{
|
||||||
|
{Title: "A", Correct: false},
|
||||||
|
{Title: "B", Correct: true},
|
||||||
|
}
|
||||||
|
item := KnowledgeCheckItem("Test question?", answers)
|
||||||
|
if item.Type != "knowledgeCheck" {
|
||||||
|
t.Errorf("KnowledgeCheckItem Type should be 'knowledgeCheck', got %q", item.Type)
|
||||||
|
}
|
||||||
|
if len(item.Items) != 1 {
|
||||||
|
t.Fatalf("KnowledgeCheckItem should have 1 sub-item, got %d", len(item.Items))
|
||||||
|
}
|
||||||
|
if len(item.Items[0].Answers) != 2 {
|
||||||
|
t.Errorf("KnowledgeCheckItem should have 2 answers, got %d", len(item.Items[0].Answers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaItem(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mediaType string
|
||||||
|
hasVideo bool
|
||||||
|
hasImage bool
|
||||||
|
}{
|
||||||
|
{"video", "video", true, false},
|
||||||
|
{"image", "image", false, true},
|
||||||
|
{"unknown", "unknown", false, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
item := MediaItem(tt.mediaType, "https://example.com/media")
|
||||||
|
if item.Type != "multimedia" {
|
||||||
|
t.Errorf("MediaItem Type should be 'multimedia', got %q", item.Type)
|
||||||
|
}
|
||||||
|
if len(item.Items) != 1 {
|
||||||
|
t.Fatalf("MediaItem should have 1 sub-item, got %d", len(item.Items))
|
||||||
|
}
|
||||||
|
if tt.hasVideo && item.Items[0].Media.Video == nil {
|
||||||
|
t.Error("Expected video media to be set")
|
||||||
|
}
|
||||||
|
if tt.hasImage && item.Items[0].Media.Image == nil {
|
||||||
|
t.Error("Expected image media to be set")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInteractiveItem(t *testing.T) {
|
||||||
|
item := InteractiveItem("Interactive Title")
|
||||||
|
if item.Type != "interactive" {
|
||||||
|
t.Errorf("InteractiveItem Type should be 'interactive', got %q", item.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDividerItem(t *testing.T) {
|
||||||
|
item := DividerItem()
|
||||||
|
if item.Type != "divider" {
|
||||||
|
t.Errorf("DividerItem Type should be 'divider', got %q", item.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlipCardItem(t *testing.T) {
|
||||||
|
item := FlipCardItem("Front text", "Back text")
|
||||||
|
if item.Type != "flashcard" {
|
||||||
|
t.Errorf("FlipCardItem Type should be 'flashcard', got %q", item.Type)
|
||||||
|
}
|
||||||
|
if len(item.Items) != 1 {
|
||||||
|
t.Fatalf("FlipCardItem should have 1 sub-item, got %d", len(item.Items))
|
||||||
|
}
|
||||||
|
if item.Items[0].Front == nil || item.Items[0].Back == nil {
|
||||||
|
t.Error("FlipCardItem should have Front and Back set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteItem(t *testing.T) {
|
||||||
|
item := QuoteItem("Test quote", "Test author")
|
||||||
|
if item.Type != "quote" {
|
||||||
|
t.Errorf("QuoteItem Type should be 'quote', got %q", item.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageItem(t *testing.T) {
|
||||||
|
item := ImageItem("https://example.com/img.jpg", "Image caption")
|
||||||
|
if item.Type != "image" {
|
||||||
|
t.Errorf("ImageItem Type should be 'image', got %q", item.Type)
|
||||||
|
}
|
||||||
|
if len(item.Items) != 1 || item.Items[0].Media == nil || item.Items[0].Media.Image == nil {
|
||||||
|
t.Error("ImageItem should have image media set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoItem(t *testing.T) {
|
||||||
|
item := VideoItem("https://example.com/video.mp4", 120, "Video caption")
|
||||||
|
if item.Type != "video" {
|
||||||
|
t.Errorf("VideoItem Type should be 'video', got %q", item.Type)
|
||||||
|
}
|
||||||
|
if len(item.Items) != 1 || item.Items[0].Media == nil || item.Items[0].Media.Video == nil {
|
||||||
|
t.Error("VideoItem should have video media set")
|
||||||
|
}
|
||||||
|
if item.Items[0].Media.Video.Duration != 120 {
|
||||||
|
t.Errorf("VideoItem duration should be 120, got %d", item.Items[0].Media.Video.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchingItem(t *testing.T) {
|
||||||
|
pairs := map[string]string{
|
||||||
|
"Term 1": "Match 1",
|
||||||
|
"Term 2": "Match 2",
|
||||||
|
}
|
||||||
|
item := MatchingItem(pairs)
|
||||||
|
if item.Type != "matching" {
|
||||||
|
t.Errorf("MatchingItem Type should be 'matching', got %q", item.Type)
|
||||||
|
}
|
||||||
|
if len(item.Items) != 1 {
|
||||||
|
t.Fatalf("MatchingItem should have 1 sub-item, got %d", len(item.Items))
|
||||||
|
}
|
||||||
|
if len(item.Items[0].Answers) != 2 {
|
||||||
|
t.Errorf("MatchingItem should have 2 answers, got %d", len(item.Items[0].Answers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyLesson(t *testing.T) {
|
||||||
|
lesson := EmptyLesson("test-id", "Test Title")
|
||||||
|
if lesson.ID != "test-id" {
|
||||||
|
t.Errorf("EmptyLesson ID mismatch: got %q", lesson.ID)
|
||||||
|
}
|
||||||
|
if lesson.Type != "lesson" {
|
||||||
|
t.Errorf("EmptyLesson Type should be 'lesson', got %q", lesson.Type)
|
||||||
|
}
|
||||||
|
if len(lesson.Items) != 0 {
|
||||||
|
t.Errorf("EmptyLesson should have no items, got %d", len(lesson.Items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSectionLesson(t *testing.T) {
|
||||||
|
lesson := SectionLesson("section-id", "Section Title")
|
||||||
|
if lesson.Type != "section" {
|
||||||
|
t.Errorf("SectionLesson Type should be 'section', got %q", lesson.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSampleVariables(t *testing.T) {
|
||||||
|
if len(SampleAnswers) == 0 {
|
||||||
|
t.Error("SampleAnswers should not be empty")
|
||||||
|
}
|
||||||
|
if len(SampleSubItems) == 0 {
|
||||||
|
t.Error("SampleSubItems should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify one correct answer exists in SampleAnswers
|
||||||
|
hasCorrect := false
|
||||||
|
for _, a := range SampleAnswers {
|
||||||
|
if a.Correct {
|
||||||
|
hasCorrect = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasCorrect {
|
||||||
|
t.Error("SampleAnswers should have at least one correct answer")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/kjanat/articulate-parser/internal/config"
|
"github.com/kjanat/articulate-parser/internal/config"
|
||||||
"github.com/kjanat/articulate-parser/internal/exporters"
|
"github.com/kjanat/articulate-parser/internal/exporters"
|
||||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
|
||||||
"github.com/kjanat/articulate-parser/internal/services"
|
"github.com/kjanat/articulate-parser/internal/services"
|
||||||
"github.com/kjanat/articulate-parser/internal/version"
|
"github.com/kjanat/articulate-parser/internal/version"
|
||||||
)
|
)
|
||||||
@@ -26,20 +27,19 @@ func main() {
|
|||||||
// run contains the main application logic and returns an exit code.
|
// run contains the main application logic and returns an exit code.
|
||||||
// This function is testable as it doesn't call os.Exit directly.
|
// This function is testable as it doesn't call os.Exit directly.
|
||||||
func run(args []string) int {
|
func run(args []string) int {
|
||||||
|
// Create context that cancels on interrupt signals (SIGINT, SIGTERM)
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
// Dependency injection setup with configuration
|
// Dependency injection setup with configuration
|
||||||
var logger interfaces.Logger
|
logger := services.NewLoggerFromConfig(cfg)
|
||||||
if cfg.LogFormat == "json" {
|
|
||||||
logger = services.NewSlogLogger(cfg.LogLevel)
|
|
||||||
} else {
|
|
||||||
logger = services.NewTextLogger(cfg.LogLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
parser := services.NewArticulateParser(logger, cfg.BaseURL, cfg.RequestTimeout)
|
parser := services.NewArticulateParser(logger, cfg)
|
||||||
exporterFactory := exporters.NewFactory(htmlCleaner)
|
exporterFactory := exporters.NewFactory(htmlCleaner, logger)
|
||||||
app := services.NewApp(parser, exporterFactory)
|
app := services.NewApp(parser, exporterFactory)
|
||||||
|
|
||||||
// Check for version flag
|
// Check for version flag
|
||||||
@@ -70,9 +70,9 @@ func run(args []string) int {
|
|||||||
|
|
||||||
// Determine if source is a URI or file path
|
// Determine if source is a URI or file path
|
||||||
if isURI(source) {
|
if isURI(source) {
|
||||||
err = app.ProcessCourseFromURI(context.Background(), source, format, output)
|
err = app.ProcessCourseFromURI(ctx, source, format, output)
|
||||||
} else {
|
} else {
|
||||||
err = app.ProcessCourseFromFile(source, format, output)
|
err = app.ProcessCourseFromFile(ctx, source, format, output)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,7 +92,7 @@ func run(args []string) int {
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - true if the string appears to be a URI, false otherwise
|
// - true if the string appears to be a URI, false otherwise
|
||||||
func isURI(str string) bool {
|
func isURI(str string) bool {
|
||||||
return len(str) > 7 && (str[:7] == "http://" || str[:8] == "https://")
|
return strings.HasPrefix(str, "http://") || strings.HasPrefix(str, "https://")
|
||||||
}
|
}
|
||||||
|
|
||||||
// printUsage prints the command-line usage information.
|
// printUsage prints the command-line usage information.
|
||||||
|
|||||||
+174
-174
@@ -20,7 +20,7 @@ DEFAULT_LDFLAGS="-s -w"
|
|||||||
|
|
||||||
# Function to show help
|
# Function to show help
|
||||||
show_help() {
|
show_help() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
articulate-parser Build Script (Bash)
|
articulate-parser Build Script (Bash)
|
||||||
=====================================
|
=====================================
|
||||||
|
|
||||||
@@ -106,129 +106,129 @@ EOF
|
|||||||
|
|
||||||
# Function to show available Go build targets
|
# Function to show available Go build targets
|
||||||
show_targets() {
|
show_targets() {
|
||||||
echo "Available Go Build Targets:"
|
echo "Available Go Build Targets:"
|
||||||
echo "=========================="
|
echo "=========================="
|
||||||
echo
|
echo
|
||||||
if command -v go >/dev/null 2>&1; then
|
if command -v go >/dev/null 2>&1; then
|
||||||
echo "Getting targets from 'go tool dist list'..."
|
echo "Getting targets from 'go tool dist list'..."
|
||||||
echo
|
echo
|
||||||
|
|
||||||
# Get all targets and format them nicely
|
# Get all targets and format them nicely
|
||||||
local targets
|
local targets
|
||||||
targets=$(go tool dist list 2>/dev/null)
|
targets=$(go tool dist list 2>/dev/null)
|
||||||
|
|
||||||
if [ $? -eq 0 ] && [ -n "$targets" ]; then
|
if [ $? -eq 0 ] && [ -n "$targets" ]; then
|
||||||
# Show formatted output
|
# Show formatted output
|
||||||
printf "%-15s %-10s %s\n" "OS" "ARCH" "STATUS"
|
printf "%-15s %-10s %s\n" "OS" "ARCH" "STATUS"
|
||||||
printf "%-15s %-10s %s\n" "---------------" "----------" "------"
|
printf "%-15s %-10s %s\n" "---------------" "----------" "------"
|
||||||
|
|
||||||
# Track our default targets
|
# Track our default targets
|
||||||
local default_targets=()
|
local default_targets=()
|
||||||
for os in "${OS[@]}"; do
|
for os in "${OS[@]}"; do
|
||||||
for arch in "${ARCH[@]}"; do
|
for arch in "${ARCH[@]}"; do
|
||||||
default_targets+=("$os/$arch")
|
default_targets+=("$os/$arch")
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
# Display all targets with status
|
# Display all targets with status
|
||||||
echo "$targets" | sort | while IFS='/' read -r os arch; do
|
echo "$targets" | sort | while IFS='/' read -r os arch; do
|
||||||
local status="available"
|
local status="available"
|
||||||
if printf '%s\n' "${default_targets[@]}" | grep -q "^$os/$arch$"; then
|
if printf '%s\n' "${default_targets[@]}" | grep -q "^$os/$arch$"; then
|
||||||
status="default"
|
status="default"
|
||||||
fi
|
fi
|
||||||
printf "%-15s %-10s %s\n" "$os" "$arch" "$status"
|
printf "%-15s %-10s %s\n" "$os" "$arch" "$status"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Summary:"
|
echo "Summary:"
|
||||||
echo " Total available targets: $(echo "$targets" | wc -l)"
|
echo " Total available targets: $(echo "$targets" | wc -l)"
|
||||||
echo " Default targets used by this script: ${#default_targets[@]}"
|
echo " Default targets used by this script: ${#default_targets[@]}"
|
||||||
echo
|
echo
|
||||||
echo "Default script targets:"
|
echo "Default script targets:"
|
||||||
for target in "${default_targets[@]}"; do
|
for target in "${default_targets[@]}"; do
|
||||||
echo " - $target"
|
echo " - $target"
|
||||||
done
|
done
|
||||||
else
|
else
|
||||||
echo "Error: Failed to get target list from 'go tool dist list'"
|
echo "Error: Failed to get target list from 'go tool dist list'"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Error: Go is not installed or not in PATH"
|
echo "Error: Go is not installed or not in PATH"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse parameters
|
# Parse parameters
|
||||||
while (("$#")); do
|
while (("$#")); do
|
||||||
case $1 in
|
case $1 in
|
||||||
-h | --help)
|
-h | --help)
|
||||||
SHOW_HELP=true
|
SHOW_HELP=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--show-targets)
|
--show-targets)
|
||||||
SHOW_TARGETS=true
|
SHOW_TARGETS=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
-v | --verbose)
|
-v | --verbose)
|
||||||
VERBOSE=true
|
VERBOSE=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
-j)
|
-j)
|
||||||
if [[ ${2-} =~ ^[0-9]+$ ]]; then
|
if [[ ${2-} =~ ^[0-9]+$ ]]; then
|
||||||
JOBS=$2
|
JOBS=$2
|
||||||
shift 2
|
shift 2
|
||||||
else
|
else
|
||||||
echo "Error: Missing number of jobs after -j"
|
echo "Error: Missing number of jobs after -j"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
-o)
|
-o)
|
||||||
if [ -n "${2-}" ]; then
|
if [ -n "${2-}" ]; then
|
||||||
OUTDIR=$2
|
OUTDIR=$2
|
||||||
shift 2
|
shift 2
|
||||||
else
|
else
|
||||||
echo "Error: Missing output directory after -o"
|
echo "Error: Missing output directory after -o"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
-e)
|
-e)
|
||||||
if [ -n "${2-}" ]; then
|
if [ -n "${2-}" ]; then
|
||||||
ENTRYPOINT=$2
|
ENTRYPOINT=$2
|
||||||
shift 2
|
shift 2
|
||||||
else
|
else
|
||||||
echo "Error: Missing entry point file after -e"
|
echo "Error: Missing entry point file after -e"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
break
|
break
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# Handle help and show-targets early
|
# Handle help and show-targets early
|
||||||
if [ "$SHOW_HELP" = true ]; then
|
if [ "$SHOW_HELP" = true ]; then
|
||||||
show_help
|
show_help
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$SHOW_TARGETS" = true ]; then
|
if [ "$SHOW_TARGETS" = true ]; then
|
||||||
show_targets
|
show_targets
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Validate Go installation
|
# Validate Go installation
|
||||||
if ! command -v go >/dev/null 2>&1; then
|
if ! command -v go >/dev/null 2>&1; then
|
||||||
echo "Error: Go is not installed or not in PATH"
|
echo "Error: Go is not installed or not in PATH"
|
||||||
echo "Please install Go from https://golang.org/dl/"
|
echo "Please install Go from https://golang.org/dl/"
|
||||||
echo "Or if running on Windows, use the PowerShell script: scripts\\build.ps1"
|
echo "Or if running on Windows, use the PowerShell script: scripts\\build.ps1"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Validate entry point exists
|
# Validate entry point exists
|
||||||
if [ ! -f "$ENTRYPOINT" ]; then
|
if [ ! -f "$ENTRYPOINT" ]; then
|
||||||
echo "Error: Entry point file '$ENTRYPOINT' does not exist"
|
echo "Error: Entry point file '$ENTRYPOINT' does not exist"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Store remaining arguments as an array to preserve argument boundaries
|
# Store remaining arguments as an array to preserve argument boundaries
|
||||||
@@ -237,30 +237,30 @@ GO_BUILD_FLAGS_ARRAY=("$@")
|
|||||||
# Apply default ldflags if no custom ldflags were provided
|
# Apply default ldflags if no custom ldflags were provided
|
||||||
HAS_CUSTOM_LDFLAGS=false
|
HAS_CUSTOM_LDFLAGS=false
|
||||||
for arg in "${GO_BUILD_FLAGS_ARRAY[@]}"; do
|
for arg in "${GO_BUILD_FLAGS_ARRAY[@]}"; do
|
||||||
if [[ "$arg" == "-ldflags" ]]; then
|
if [[ "$arg" == "-ldflags" ]]; then
|
||||||
HAS_CUSTOM_LDFLAGS=true
|
HAS_CUSTOM_LDFLAGS=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ "$HAS_CUSTOM_LDFLAGS" == false ]] && [[ -n "$DEFAULT_LDFLAGS" ]]; then
|
if [[ "$HAS_CUSTOM_LDFLAGS" == false ]] && [[ -n "$DEFAULT_LDFLAGS" ]]; then
|
||||||
# Add default ldflags at the beginning
|
# Add default ldflags at the beginning
|
||||||
GO_BUILD_FLAGS_ARRAY=("-ldflags" "$DEFAULT_LDFLAGS" "${GO_BUILD_FLAGS_ARRAY[@]}")
|
GO_BUILD_FLAGS_ARRAY=("-ldflags" "$DEFAULT_LDFLAGS" "${GO_BUILD_FLAGS_ARRAY[@]}")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verbose output
|
# Verbose output
|
||||||
if [ "$VERBOSE" = true ]; then
|
if [ "$VERBOSE" = true ]; then
|
||||||
echo "Build Configuration:"
|
echo "Build Configuration:"
|
||||||
echo " Entry Point: $ENTRYPOINT"
|
echo " Entry Point: $ENTRYPOINT"
|
||||||
echo " Output Dir: $OUTDIR"
|
echo " Output Dir: $OUTDIR"
|
||||||
echo " Parallel Jobs: $JOBS"
|
echo " Parallel Jobs: $JOBS"
|
||||||
if [ ${#GO_BUILD_FLAGS_ARRAY[@]} -gt 0 ]; then
|
if [ ${#GO_BUILD_FLAGS_ARRAY[@]} -gt 0 ]; then
|
||||||
echo " Go Build Flags: ${GO_BUILD_FLAGS_ARRAY[*]}"
|
echo " Go Build Flags: ${GO_BUILD_FLAGS_ARRAY[*]}"
|
||||||
else
|
else
|
||||||
echo " Go Build Flags: none"
|
echo " Go Build Flags: none"
|
||||||
fi
|
fi
|
||||||
echo " Targets: ${#OS[@]}×${#ARCH[@]} = $((${#OS[@]} * ${#ARCH[@]})) total"
|
echo " Targets: ${#OS[@]}×${#ARCH[@]} = $((${#OS[@]} * ${#ARCH[@]})) total"
|
||||||
echo
|
echo
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -rf "$OUTDIR"
|
rm -rf "$OUTDIR"
|
||||||
@@ -272,27 +272,27 @@ BUILD_START=$(date +%s)
|
|||||||
# Compose all targets in an array
|
# Compose all targets in an array
|
||||||
TARGETS=()
|
TARGETS=()
|
||||||
for os in "${OS[@]}"; do
|
for os in "${OS[@]}"; do
|
||||||
for arch in "${ARCH[@]}"; do
|
for arch in "${ARCH[@]}"; do
|
||||||
BIN="articulate-parser-$os-$arch"
|
BIN="articulate-parser-$os-$arch"
|
||||||
[[ "$os" == "windows" ]] && BIN="$BIN.exe"
|
[[ "$os" == "windows" ]] && BIN="$BIN.exe"
|
||||||
TARGETS+=("$BIN|$os|$arch")
|
TARGETS+=("$BIN|$os|$arch")
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
# Show targets info if verbose
|
# Show targets info if verbose
|
||||||
if [ "$VERBOSE" = true ]; then
|
if [ "$VERBOSE" = true ]; then
|
||||||
echo "Building targets:"
|
echo "Building targets:"
|
||||||
for target in "${TARGETS[@]}"; do
|
for target in "${TARGETS[@]}"; do
|
||||||
BIN="${target%%|*}"
|
BIN="${target%%|*}"
|
||||||
echo " - $BIN"
|
echo " - $BIN"
|
||||||
done
|
done
|
||||||
echo
|
echo
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Print pending statuses and save line numbers
|
# Print pending statuses and save line numbers
|
||||||
for idx in "${!TARGETS[@]}"; do
|
for idx in "${!TARGETS[@]}"; do
|
||||||
BIN="${TARGETS[$idx]%%|*}"
|
BIN="${TARGETS[$idx]%%|*}"
|
||||||
printf "[ ] %-35s ... pending\n" "$BIN"
|
printf "[ ] %-35s ... pending\n" "$BIN"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Make sure output isn't buffered
|
# Make sure output isn't buffered
|
||||||
@@ -300,36 +300,36 @@ export PYTHONUNBUFFERED=1
|
|||||||
|
|
||||||
# Function to update a line in-place (1-based index)
|
# Function to update a line in-place (1-based index)
|
||||||
update_status() {
|
update_status() {
|
||||||
local idx=$1
|
local idx=$1
|
||||||
local symbol=$2
|
local symbol=$2
|
||||||
local msg=$3
|
local msg=$3
|
||||||
# Move cursor up to the correct line
|
# Move cursor up to the correct line
|
||||||
printf "\0337" # Save cursor position
|
printf "\0337" # Save cursor position
|
||||||
printf "\033[%dA" $((${#TARGETS[@]} - idx + 1)) # Move up
|
printf "\033[%dA" $((${#TARGETS[@]} - idx + 1)) # Move up
|
||||||
printf "\r\033[K[%s] %-35s\n" "$symbol" "$msg" # Clear & update line
|
printf "\r\033[K[%s] %-35s\n" "$symbol" "$msg" # Clear & update line
|
||||||
printf "\0338" # Restore cursor position
|
printf "\0338" # Restore cursor position
|
||||||
}
|
}
|
||||||
|
|
||||||
for idx in "${!TARGETS[@]}"; do
|
for idx in "${!TARGETS[@]}"; do
|
||||||
while (($(jobs -rp | wc -l) >= JOBS)); do sleep 0.2; done
|
while (($(jobs -rp | wc -l) >= JOBS)); do sleep 0.2; done
|
||||||
(
|
(
|
||||||
IFS='|' read -r BIN os arch <<<"${TARGETS[$idx]}"
|
IFS='|' read -r BIN os arch <<<"${TARGETS[$idx]}"
|
||||||
update_status $((idx + 1)) '>' "$BIN ... building"
|
update_status $((idx + 1)) '>' "$BIN ... building"
|
||||||
|
|
||||||
# Prepare build command as an array to properly handle arguments with spaces
|
# Prepare build command as an array to properly handle arguments with spaces
|
||||||
build_cmd=(go build)
|
build_cmd=(go build)
|
||||||
if [ "$VERBOSE" = true ]; then
|
if [ "$VERBOSE" = true ]; then
|
||||||
build_cmd+=(-v)
|
build_cmd+=(-v)
|
||||||
fi
|
fi
|
||||||
build_cmd+=("${GO_BUILD_FLAGS_ARRAY[@]}" -o "$OUTDIR/$BIN" "$ENTRYPOINT")
|
build_cmd+=("${GO_BUILD_FLAGS_ARRAY[@]}" -o "$OUTDIR/$BIN" "$ENTRYPOINT")
|
||||||
|
|
||||||
if CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" "${build_cmd[@]}" 2>"$OUTDIR/$BIN.log"; then
|
if CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" "${build_cmd[@]}" 2>"$OUTDIR/$BIN.log"; then
|
||||||
update_status $((idx + 1)) '✔' "$BIN done"
|
update_status $((idx + 1)) '✔' "$BIN done"
|
||||||
rm -f "$OUTDIR/$BIN.log"
|
rm -f "$OUTDIR/$BIN.log"
|
||||||
else
|
else
|
||||||
update_status $((idx + 1)) '✖' "$BIN FAILED (see $OUTDIR/$BIN.log)"
|
update_status $((idx + 1)) '✖' "$BIN FAILED (see $OUTDIR/$BIN.log)"
|
||||||
fi
|
fi
|
||||||
) &
|
) &
|
||||||
done
|
done
|
||||||
|
|
||||||
wait
|
wait
|
||||||
@@ -342,27 +342,27 @@ echo -e "\nAll builds completed in ${BUILD_DURATION}s. Find them in $OUTDIR/"
|
|||||||
|
|
||||||
# Show build summary if verbose
|
# Show build summary if verbose
|
||||||
if [ "$VERBOSE" = true ]; then
|
if [ "$VERBOSE" = true ]; then
|
||||||
echo
|
echo
|
||||||
echo "Build Summary:"
|
echo "Build Summary:"
|
||||||
echo "=============="
|
echo "=============="
|
||||||
success_count=0
|
success_count=0
|
||||||
total_size=0
|
total_size=0
|
||||||
|
|
||||||
for target in "${TARGETS[@]}"; do
|
for target in "${TARGETS[@]}"; do
|
||||||
BIN="${target%%|*}"
|
BIN="${target%%|*}"
|
||||||
if [ -f "$OUTDIR/$BIN" ]; then
|
if [ -f "$OUTDIR/$BIN" ]; then
|
||||||
success_count=$((success_count + 1))
|
success_count=$((success_count + 1))
|
||||||
size=$(stat -f%z "$OUTDIR/$BIN" 2>/dev/null || stat -c%s "$OUTDIR/$BIN" 2>/dev/null || echo "0")
|
size=$(stat -f%z "$OUTDIR/$BIN" 2>/dev/null || stat -c%s "$OUTDIR/$BIN" 2>/dev/null || echo "0")
|
||||||
total_size=$((total_size + size))
|
total_size=$((total_size + size))
|
||||||
rm -f "$OUTDIR/$BIN.log"
|
rm -f "$OUTDIR/$BIN.log"
|
||||||
printf " ✔ %-42s %s\n" "$OUTDIR/$BIN" "$(numfmt --to=iec-i --suffix=B $size 2>/dev/null || echo "${size} bytes")"
|
printf " ✔ %-42s %s\n" "$OUTDIR/$BIN" "$(numfmt --to=iec-i --suffix=B $size 2>/dev/null || echo "${size} bytes")"
|
||||||
else
|
else
|
||||||
printf " ✖ %-42s %s\n" "$OUTDIR/$BIN" "FAILED"
|
printf " ✖ %-42s %s\n" "$OUTDIR/$BIN" "FAILED"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo " ────────────────────────────────────────────────"
|
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")"
|
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
|
fi
|
||||||
|
|
||||||
# Clean up environment variables to avoid contaminating future builds
|
# Clean up environment variables to avoid contaminating future builds
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# :schema tombi://www.schemastore.org/tombi.json
|
||||||
|
# :tombi toml-version = "v1.1.0"
|
||||||
|
|
||||||
|
toml-version = "v1.1.0"
|
||||||
|
|
||||||
|
[files]
|
||||||
|
include = ["**/*.toml", "**/.*.toml"]
|
||||||
|
exclude = ["**/fixtures"]
|
||||||
|
|
||||||
|
[format]
|
||||||
|
[format.rules]
|
||||||
|
array-bracket-space-width = 0
|
||||||
|
array-comma-space-width = 1
|
||||||
|
date-time-delimiter = "T"
|
||||||
|
indent-style = "space"
|
||||||
|
indent-sub-tables = false
|
||||||
|
indent-table-key-value-pairs = false
|
||||||
|
indent-width = 2
|
||||||
|
inline-table-brace-space-width = 1
|
||||||
|
inline-table-comma-space-width = 1
|
||||||
|
key-value-equals-sign-alignment = true
|
||||||
|
key-value-equals-sign-space-width = 1
|
||||||
|
line-ending = "lf"
|
||||||
|
line-width = 140
|
||||||
|
string-quote-style = "double"
|
||||||
|
trailing-comment-alignment = true
|
||||||
|
trailing-comment-space-width = 2
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
[lint.rules]
|
||||||
|
dotted-keys-out-of-order = "warn"
|
||||||
|
key-empty = "warn"
|
||||||
|
tables-out-of-order = "error"
|
||||||
|
|
||||||
|
[lsp]
|
||||||
|
code-action.enabled = true
|
||||||
|
completion.enabled = true
|
||||||
|
diagnostic.enabled = true
|
||||||
|
document-link.enabled = true
|
||||||
|
formatting.enabled = true
|
||||||
|
goto-declaration.enabled = true
|
||||||
|
goto-definition.enabled = true
|
||||||
|
goto-type-definition.enabled = true
|
||||||
|
hover.enabled = true
|
||||||
|
workspace-diagnostic.enabled = true
|
||||||
|
|
||||||
|
[schema]
|
||||||
|
enabled = true
|
||||||
|
strict = true
|
||||||
Reference in New Issue
Block a user