Author SHA1 Message Date
kjanat 76f8017ea7 chore: bump actions, reflow configs, fix examples
Bump codecov/codecov-action to v7 and docker/metadata-action
to v6 in the CI and release workflows.

Reflow .golangci.yml and html_styles.css under
the prettier plugin dprint now loads.

Exporter examples hand their export to the temp-dir helper instead of
pairing defer cleanup with log.Fatal. log.Fatal exits the process, so
the deferred cleanup never ran and a failed export left its temp dir
behind. gocritic reported it as exitAfterDefer.
2026-09-21 10:10:40 +02:00
kjanat 7d3487312f chore: adopt remote dprint config, tidy examples
Extend dprint from a shared remote config and add the prettier
and exec plugins, formatting TOML via a new tombi.toml instead
of golangci-lint fmt. Taskfile.yml, scripts/build.sh, and
html_styles.css pick up the resulting reformat.

Exporter examples now write to a temp dir and clean up after
themselves instead of leaving output.md/.docx/.html in the
repo. Drop the stray internal/exporters/output.docx and ignore
internal/exporters/output.* going forward.
2026-09-21 09:47:10 +02:00
kjanat defce7d2d3 refactor: extract base exporter, add sentinel errors, document unused fields 2026-09-21 09:46:39 +02:00
kjanat 74906ee6a3 refactor: extract HTMLCleaner interface and consolidate constants
- Add interfaces.HTMLCleaner interface for better testability
- Add CleanAndTrim() method to simplify repeated pattern
- Move item/lesson type constants to models/item_types.go
- Update all exporters to use interface instead of concrete type
- Replace strings.TrimSpace(CleanHTML(...)) with CleanAndTrim()
2026-09-21 09:46:16 +02:00
kjanat adb60ca7e5 refactor: comprehensive code quality improvements
- Add logger to all exporters for consistent error handling
- Add Course.Validate() method with ValidationError type
- Create shared test fixtures in internal/testdata
- Refactor config passing: NewArticulateParser now takes *config.Config
- Add NewLoggerFromConfig() factory for logger creation
- Add context cancellation with signal handling (SIGINT/SIGTERM)
- Add godoc examples for loggers and exporters
- Add HTML exporter error path tests
- Add fuzz tests for parser and HTMLCleaner
- Fix markdown output: trim whitespace, collapse blank lines
- Remove test output files from repo
2026-09-21 09:45:05 +02:00
kjanat 8ec9dd4f20 Merge pull request #40 from kjanat/dependabot/github_actions/go-task/setup-task-2
ci(deps): bump go-task/setup-task from 1 to 2
2026-09-14 12:12:49 +02:00
kjanat b3c6403635 Merge pull request #41 from kjanat/dependabot/github_actions/actions/checkout-7
ci(deps): bump actions/checkout from 6 to 7
2026-09-14 12:12:31 +02:00
kjanat af24ec35df Merge pull request #42 from kjanat/dependabot/go_modules/go-modules-08d23545fa
deps(deps): bump the go-modules group with 2 updates
2026-09-14 12:12:10 +02:00
dependabot[bot] b24646153c deps(deps): bump the go-modules group with 2 updates
Bumps the go-modules group with 2 updates: [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/text](https://github.com/golang/text).


Updates `golang.org/x/net` from 0.58.0 to 0.59.0
- [Commits](https://github.com/golang/net/compare/v0.58.0...v0.59.0)

Updates `golang.org/x/text` from 0.41.0 to 0.42.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules
- dependency-name: golang.org/x/text
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 05:08:06 +00:00
dependabot[bot] 1212cbf559 ci(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-07 05:08:54 +00:00
dependabot[bot] 66abd5f1a7 ci(deps): bump go-task/setup-task from 1 to 2
Bumps [go-task/setup-task](https://github.com/go-task/setup-task) from 1 to 2.
- [Release notes](https://github.com/go-task/setup-task/releases)
- [Changelog](https://github.com/go-task/setup-task/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-task/setup-task/compare/v1...v2)

---
updated-dependencies:
- dependency-name: go-task/setup-task
  dependency-version: '2'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-07 05:08:40 +00:00
kjanat c3b14f7567 Merge pull request #37 from kjanat/dependabot/github_actions/docker/setup-buildx-action-4
ci(deps): bump docker/setup-buildx-action from 3 to 4
2026-08-31 09:42:12 +02:00
kjanat 3806a35838 Merge pull request #38 from kjanat/dependabot/go_modules/go-modules-af9fcaddce
deps(deps): bump the go-modules group with 2 updates
2026-08-31 09:41:54 +02:00
kjanat 2a251f78f8 Merge pull request #39 from kjanat/dependabot/github_actions/actions/setup-go-7
ci(deps): bump actions/setup-go from 6 to 7
2026-08-31 09:41:26 +02:00
dependabot[bot] 4bc12af856 ci(deps): bump actions/setup-go from 6 to 7
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 05:07:32 +00:00
dependabot[bot] 27593b1391 deps(deps): bump the go-modules group with 2 updates
Bumps the go-modules group with 2 updates: [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/text](https://github.com/golang/text).


Updates `golang.org/x/net` from 0.57.0 to 0.58.0
- [Commits](https://github.com/golang/net/compare/v0.57.0...v0.58.0)

Updates `golang.org/x/text` from 0.40.0 to 0.41.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.40.0...v0.41.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.58.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules
- dependency-name: golang.org/x/text
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 05:07:30 +00:00
dependabot[bot] c2b2ad2027 ci(deps): bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 05:07:27 +00:00
kjanatanddependabot[bot] fe35fb63d1 ci(deps): bump actions/upload-artifact from 6 to 7 (#32)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-26 00:20:07 +02:00
kjanatanddependabot[bot] eb2f9c43b1 ci(deps): bump docker/build-push-action from 6 to 7 (#33)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-26 00:20:06 +02:00
dependabot[bot] ce53343d10 deps(deps): bump the go-modules group with 2 updates (#34)
Bumps the go-modules group with 2 updates: [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/text](https://github.com/golang/text).


Updates `golang.org/x/net` from 0.56.0 to 0.57.0
- [Commits](https://github.com/golang/net/compare/v0.56.0...v0.57.0)

Updates `golang.org/x/text` from 0.38.0 to 0.40.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.38.0...v0.40.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules
- dependency-name: golang.org/x/text
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-26 00:18:26 +02:00
dependabot[bot] 87f60c56f5 docker(deps): bump golang in the docker group (#35)
Bumps the docker group with 1 update: golang.


Updates `golang` from 1.26-alpine to 1.27-alpine

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-26 00:17:30 +02:00
dependabot[bot] 43a83e4004 ci(deps): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-26 19:41:57 +02:00
dependabot[bot] d966bd1aa4 ci(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-26 19:41:39 +02:00
kjanat 144125d355 fix: resolve golangci-lint CI failures, simplify CI, tidy docs (#29)
* fix(lint): resolve golangci-lint failures and modernize string handling

CI's golangci-lint (v2.12.2) job was failing with 14 issues, which blocked
the dependent test job. This addresses all of them:

- goconst: extract repeated string literals into constants
  - format aliases ("md", "word", "htm") in the exporter factory
  - "section" lesson type shared by markdown and HTML exporters
  - default Articulate Rise base URL and host in the parser
  - reuse existing itemType* constants in the markdown switch
- staticcheck (QF1012): replace buf.WriteString(fmt.Sprintf(...)) with
  fmt.Fprintf(...) in the markdown exporter

Also drop the hardcoded `go: "1.24"` from .golangci.yml so the target Go
version is autodetected from go.mod.

* ci: drop test matrix, run a single Go version from go.mod

The test job ran a 1.24.x/1.25.x matrix, but go.mod requires go 1.25.0, so the
1.24 entry just auto-downloaded the 1.25 toolchain and tested the same thing
twice. Replace the matrix with a single job that sources its Go version from
go.mod via go-version-file, and reference the resolved version through the
setup-go step output in summaries, artifact names, and Codecov flags.

* docs: remove emojis from README

* ci: pin modernize tool to gopls v0.21.x for Go 1.25 compatibility

The autofix workflow's `task modernize` step installed the modernize
analyzer from gopls@latest, which as of v0.22.0 requires Go 1.26. The
project targets Go 1.25 (go.mod) and CI runs with GOTOOLCHAIN=local, so
the install failed. Pin to the v0.21.x line, which supports Go >= 1.25.

* ci: keep modernize@latest, fetch its toolchain via GOTOOLCHAIN=auto

Replaces the earlier v0.21.0 pin. The modernize analyzer (gopls v0.22+)
requires Go 1.26, which the project doesn't target yet — and bumping the
module to 1.26 isn't viable because the current golangci-lint release is
built with Go 1.25 and refuses to lint a newer target. Instead, let the
modernize task fetch the toolchain it needs on demand via GOTOOLCHAIN=auto
(setup-go pins GOTOOLCHAIN=local in CI), so we stay on the latest analyzer
without touching the module's Go version.

Also bump golang.org/x/image v0.34.0 -> v0.42.0 via `go get -u ./...`.

* ci: set GOTOOLCHAIN=auto inline for the modernize task

A task-level env: entry does not override GOTOOLCHAIN when setup-go has
already exported GOTOOLCHAIN=local job-wide, so the autofix job still
failed. Set GOTOOLCHAIN=auto inline on the modernize command itself, which
reliably overrides the inherited value and lets Go fetch the toolchain the
modernize analyzer requires.

* ci: bump to Go 1.26 and address PR review feedback

The autofix failure is fixed properly by moving to Go 1.26 instead of the
GOTOOLCHAIN workaround. The official golangci-lint v2.12.2 binary is built
with go1.26.2 and lints a Go 1.26 target fine (the earlier "not ready"
claim was from a locally go-installed binary compiled with Go 1.25), and
the Dockerfiles already use golang:1.26-alpine, so this also aligns the
module with the images.

- go.mod: go 1.25.0 -> 1.26.0, drop the toolchain pin (keeps the lint
  target at the go directive).
- Taskfile: revert modernize to plain modernize@latest; on Go 1.26 the
  GOTOOLCHAIN dance and its comments are unnecessary.
- ci.yml: pass the resolved Go version through an env var (GO_VERSION)
  instead of interpolating steps.setup-go.outputs.go-version directly into
  shell scripts (script-injection hygiene); grant the dependency-review job
  pull-requests: write so it can post its summary.
- parser.go: derive defaultBaseURL from riseHost instead of duplicating the
  host string; drop the redundant per-const comments.
- .golangci.yml: remove the redundant go-version comment.
- regenerate internal/exporters/output.docx.
2026-06-15 21:46:57 +02:00
dependabot[bot] 9ea51458c3 docker(deps): bump golang in the docker group across 1 directory
Bumps the docker group with 1 update in the / directory: golang.


Updates `golang` from 1.25-alpine to 1.26-alpine

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 18:50:58 +02:00
dependabot[bot] 9b31fcda9e ci(deps): bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 18:50:37 +02:00
dependabot[bot] 6e5c6aa086 ci(deps): bump actions/dependency-review-action from 4 to 5
Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4 to 5.
- [Release notes](https://github.com/actions/dependency-review-action/releases)
- [Commits](https://github.com/actions/dependency-review-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/dependency-review-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 18:50:33 +02:00
dependabot[bot] 6ba44a75eb deps(deps): bump the go-modules group with 2 updates
Bumps the go-modules group with 2 updates: [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/text](https://github.com/golang/text).


Updates `golang.org/x/net` from 0.50.0 to 0.56.0
- [Commits](https://github.com/golang/net/compare/v0.50.0...v0.56.0)

Updates `golang.org/x/text` from 0.34.0 to 0.38.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.34.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules
- dependency-name: golang.org/x/text
  dependency-version: 0.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 18:50:19 +02:00
dependabot[bot] 5890b1d3b9 deps(deps): bump the go-modules group with 2 updates
Bumps the go-modules group with 2 updates: [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/text](https://github.com/golang/text).


Updates `golang.org/x/net` from 0.49.0 to 0.50.0
- [Commits](https://github.com/golang/net/compare/v0.49.0...v0.50.0)

Updates `golang.org/x/text` from 0.33.0 to 0.34.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.33.0...v0.34.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules
- dependency-name: golang.org/x/text
  dependency-version: 0.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 01:51:28 +02:00
dependabot[bot] 24ebea1791 deps(deps): bump the go-modules group across 1 directory with 2 updates
Bumps the go-modules group with 1 update in the / directory: [golang.org/x/net](https://github.com/golang/net).


Updates `golang.org/x/net` from 0.48.0 to 0.49.0
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.49.0)

Updates `golang.org/x/text` from 0.32.0 to 0.33.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.32.0...v0.33.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules
- dependency-name: golang.org/x/text
  dependency-version: 0.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-20 00:03:28 +01:00
kjanat 94a7924bed refactor: improve code quality and consistency
- parser.go: compile regex once at package level (perf)
- parser.go: include response body in HTTP error messages (debug)
- main.go: use strings.HasPrefix for URI detection (safety)
- html.go: handle file close errors consistently
- docx.go: extract font size magic numbers to constants
- markdown.go: normalize item types to lowercase for consistency
2026-01-05 11:54:31 +01:00
kjanat 90b9d557d8 fix: quote shell variables in CI workflow for shellcheck compliance 2026-01-05 04:17:16 +01:00
kjanat 33ff267644 fix: restore pre-commit, CGO_ENABLED, gohtml template
- Add CGO_ENABLED=1 to CI test step for race detection
- Fix docker job needs (remove dependency-review, only runs on PRs)
- Restore .pre-commit-config.yaml for local dev safety
- Rename html_template.html to .gohtml (conventional extension)
- Add GitHub URL and default branch info to AGENTS.md
- Add .dprint.jsonc config
- Various formatting normalization
2026-01-05 04:14:56 +01:00
kjanat 33673d661b fix: set go 1.24.0 minimum with toolchain 1.25.5 2026-01-05 03:31:09 +01:00
autofix-ci[bot] 41f3f5c4e2 [autofix.ci] apply automated fixes 2026-01-05 02:26:28 +00:00
kjanat d644094999 chore: enable CGO for race detection, update deps, drop old Go versions 2026-01-05 03:24:49 +01:00
kjanat 71d1429048 chore: update actions/checkout to v6, improve AGENTS.md 2026-01-05 03:17:26 +01:00
58 changed files with 2899 additions and 1339 deletions
+19
View File
@@ -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"],
}],
},
}
+11 -11
View File
@@ -45,23 +45,23 @@ Enhancement suggestions are welcome! Please use the feature request template and
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
+5 -5
View File
@@ -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:
@@ -27,9 +27,9 @@ body:
2. Parse file '...' 2. Parse file '...'
3. See error 3. See error
value: | value: |
1. 1.
2. 2.
3. 3.
validations: validations:
required: true required: true
+4
View File
@@ -5,11 +5,13 @@
## 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) - [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality) - [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
@@ -21,6 +23,7 @@ Fixes #
## 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 - [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code - [ ] I have performed a self-review of my code
- [ ] I have added comments to complex logic - [ ] I have added comments to complex logic
@@ -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.
+49 -49
View File
@@ -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"
+6 -6
View File
@@ -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,18 +11,18 @@ 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: |
# Install golangci-lint # Install golangci-lint
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b "$(go env GOPATH)/bin" curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin
# Install go-task dependencies # Install go-task dependencies
go install golang.org/x/tools/cmd/goimports@latest go install golang.org/x/tools/cmd/goimports@latest
+68 -63
View File
@@ -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,23 +35,26 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
strategy:
matrix:
go:
- 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
@@ -70,7 +73,7 @@ jobs:
{ {
cat << 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)
@@ -83,14 +86,14 @@ jobs:
# Extract test results for summary # Extract test results for summary
TEST_STATUS=$? TEST_STATUS=$?
TOTAL_TESTS=$(grep -c "=== RUN" test-output.log || echo "0") TOTAL_TESTS=$(grep -c "=== RUN" test-output.log || echo "0")
PASSED_TESTS=$(grep -c -- "--- PASS:" test-output.log || echo "0") PASSED_TESTS=$(grep -c "--- PASS:" test-output.log || echo "0")
FAILED_TESTS=$(grep -c -- "--- FAIL:" test-output.log || echo "0") FAILED_TESTS=$(grep -c "--- FAIL:" test-output.log || echo "0")
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 << EOF cat << EOF
## 🧪 Test Results (Go ${{ matrix.go }}) ## 🧪 Test Results (Go $GO_VERSION)
| Metric | Value | | Metric | Value |
| ----------- | ------------------------------------------------------------- | | ----------- | ------------------------------------------------------------- |
@@ -106,31 +109,31 @@ 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 |" 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 |" echo "| $pkg | ❌ FAIL |"
fi fi
done done
echo "" 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 << 'EOF' cat << 'EOF'
### ❌ Failed Tests Details ### ❌ Failed Tests Details
``` ```
EOF EOF
grep -A 10 -- "--- FAIL:" test-output.log | head -100 grep -A 10 -- "--- FAIL:" test-output.log | head -100
cat << 'EOF' cat << 'EOF'
``` ```
EOF EOF
fi fi
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
# Set outputs for other steps # Set outputs for other steps
@@ -152,7 +155,7 @@ jobs:
{ {
cat << EOF cat << EOF
## 📊 Code Coverage (Go ${{ matrix.go }}) ## 📊 Code Coverage (Go $GO_VERSION)
**Total Coverage: $COVERAGE** **Total Coverage: $COVERAGE**
@@ -211,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/
@@ -221,10 +224,9 @@ jobs:
- name: Run linters - name: Run linters
run: | run: |
# Initialize summary
{ {
cat << EOF cat << EOF
## 🔍 Static Analysis (Go ${{ matrix.go }}) ## 🔍 Static Analysis (Go $GO_VERSION)
EOF EOF
@@ -268,9 +270,10 @@ jobs:
- 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 |
@@ -280,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 }}
@@ -293,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:
@@ -304,19 +308,19 @@ 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
@@ -349,13 +353,14 @@ jobs:
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
@@ -366,37 +371,37 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
needs: [test] 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 }}
@@ -417,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.
+50 -50
View File
@@ -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}}"
+4 -4
View File
@@ -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
+11 -11
View File
@@ -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: |
+2
View File
@@ -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
View File
@@ -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:
+25
View File
@@ -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"],
},
},
}
+11 -11
View File
@@ -9,7 +9,7 @@ repos:
exclude: '^\.github/ISSUE_TEMPLATE/.*\.yml$' exclude: '^\.github/ISSUE_TEMPLATE/.*\.yml$'
- id: end-of-file-fixer - id: end-of-file-fixer
- id: mixed-line-ending - id: mixed-line-ending
args: ['--fix=lf'] args: ["--fix=lf"]
# File validation # File validation
- id: check-yaml - id: check-yaml
@@ -23,7 +23,7 @@ repos:
- id: check-merge-conflict - id: check-merge-conflict
- id: check-case-conflict - id: check-case-conflict
- id: no-commit-to-branch - id: no-commit-to-branch
args: ['--branch=master', '--branch=main'] args: ["--branch=master", "--branch=main"]
# File structure # File structure
- id: check-added-large-files - id: check-added-large-files
@@ -31,16 +31,16 @@ repos:
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
- repo: local - repo: local
hooks: hooks:
- id: actionlint - id: actionlint
name: Lint GitHub Actions workflow files name: Lint GitHub Actions workflow files
description: Runs actionlint to lint GitHub Actions workflow files description: Runs actionlint to lint GitHub Actions workflow files
language: golang language: golang
types: ["yaml"] types: ["yaml"]
files: ^\.github/workflows/ files: ^\.github/workflows/
entry: actionlint entry: actionlint
minimum_pre_commit_version: 3.0.0 minimum_pre_commit_version: 3.0.0
- repo: https://github.com/golangci/golangci-lint - repo: https://github.com/golangci/golangci-lint
rev: v2.6.1 rev: v2.7.2
hooks: hooks:
- id: golangci-lint - id: golangci-lint
name: golangci-lint name: golangci-lint
+40
View File
@@ -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"
]
}
}
}
}
+129 -373
View File
@@ -1,36 +1,87 @@
# 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` ### Primary Commands (using Taskfile)
- **Run tests**: `task test` or `go test -race -timeout 5m ./...`
- **Run single test**: `go test -v -race -run ^TestName$ ./path/to/package` ```bash
- **Test with coverage**: task build # Build binary to bin/articulate-parser
- `task test:coverage` or task test # Run all tests with race detection
- `go test -race -coverprofile=coverage/coverage.out -covermode=atomic ./...` task lint # Run all linters (vet, fmt, staticcheck, golangci-lint)
- **Lint**: `task lint` (runs vet, fmt check, staticcheck, golangci-lint) task fmt # Format all Go files
- **Format**: `task fmt` or `gofmt -s -w .` task ci # Full CI pipeline: deps, lint, test with coverage, build
- **CI checks**: `task ci` (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
@@ -41,6 +92,20 @@
- 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
@@ -49,379 +114,70 @@
### Security ### Security
- Use `#nosec` with justification for deliberate security exceptions (G304 for CLI file paths, G306 for export file permissions) - Use `#nosec` with justification for deliberate security exceptions
- Run `gosec` and `govulncheck` for security audits - 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
## Go 1.24 & 1.25 New Features Reference ```
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
```
This project uses Go 1.24+. Below is a comprehensive summary of new features and changes in Go 1.24 and 1.25 that may be relevant for development and maintenance. ## Common Patterns
### Go 1.24 Major Changes (Released February 2025) ### Creating a new exporter
#### Language Features 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
**Generic Type Aliases** ### Adding configuration options
- Type aliases can now be parameterized with type parameters 1. Add field to `Config` struct in `internal/config/config.go`
- Example: `type List[T any] = []T` 2. Load from environment variable with sensible default
- Can be disabled via `GOEXPERIMENT=noaliastypeparams` (removed in 1.25) 3. Document in config struct comments
#### Tooling Enhancements
**Module Tool Dependencies**
- New `tool` directive in go.mod tracks executable dependencies
- Use `go get -tool <package>` to add tool dependencies
- Use `go install tool` and `go get tool` to manage them
- Eliminates need for blank imports in `tools.go` files
**Build Output Formatting**
- Both `go build` and `go test` support `-json` flag for structured JSON output
- New action types distinguish build output from test results
**Authentication**
- New `GOAUTH` environment variable provides flexible authentication for private modules
**Automatic Version Tracking**
- `go build` automatically sets main module version in binaries based on VCS tags
- Adds `+dirty` suffix for uncommitted changes
**Cgo Performance Improvements**
- New `#cgo noescape` annotation: Prevents escape analysis overhead for C function calls
- New `#cgo nocallback` annotation: Indicates C function won't call back to Go
**Toolchain Tracing**
- `GODEBUG=toolchaintrace=1` enables tracing of toolchain selection
#### Runtime & Performance
**Performance Improvements**
- **2-3% CPU overhead reduction** across benchmark suites
- New Swiss Tables-based map implementation (faster lookups)
- Disable via `GOEXPERIMENT=noswissmap`
- More efficient small object allocation
- Redesigned runtime-internal mutexes
- Disable via `GOEXPERIMENT=nospinbitmutex`
#### Compiler & Linker
**Method Receiver Restrictions**
- Methods on cgo-generated types now prevented (both directly and through aliases)
**Build IDs**
- Linkers generate GNU build IDs (ELF) and UUIDs (macOS) by default
- Disable via `-B none` flag
#### Standard Library Additions
**File System Safety - `os.Root`**
- New `os.Root` type enables directory-limited operations
- Prevents path escape and symlink breakouts
- Essential for sandboxed file operations
**Cryptography Expansion**
- `crypto/mlkem`: ML-KEM-768/1024 post-quantum key exchange (FIPS 203)
- `crypto/hkdf`: HMAC-based Extract-and-Expand KDF (RFC 5869)
- `crypto/pbkdf2`: Password-based key derivation (RFC 8018)
- `crypto/sha3`: SHA-3 and SHAKE functions (FIPS 202)
**FIPS 140-3 Support**
- New `GOFIPS140` environment variable enables FIPS mode
- New `fips140` GODEBUG setting for cryptographic module compliance
**Weak References - `weak` Package**
- New `weak` package provides low-level weak pointers
- Enables memory-efficient structures like weak maps and caches
- Useful for preventing memory leaks in cache implementations
**Testing Improvements**
- `testing.B.Loop()`: Cleaner syntax replacing manual `b.N` iteration
- Prevents compiler from optimizing away benchmarked code
- New `testing/synctest` package (experimental) for testing concurrent code with fake clocks
**Iterator Support**
- Multiple packages now offer iterator-returning variants:
- `bytes`: Iterator-based functions
- `strings`: Iterator-based functions
- `go/types`: Iterator support
#### Security Enhancements
**TLS Post-Quantum Cryptography**
- `X25519MLKEM768` hybrid key exchange enabled by default in TLS
- Provides quantum-resistant security
**Encrypted Client Hello (ECH)**
- TLS servers can enable ECH via `Config.EncryptedClientHelloKeys`
- Protects client identity during TLS handshake
**RSA Key Validation**
- Keys smaller than 1024 bits now rejected by default
- Use `GODEBUG=rsa1024min=0` to revert (testing only)
**Constant-Time Execution**
- New `crypto/subtle.WithDataIndependentTiming()` enables architecture-specific timing guarantees
- Helps prevent timing attacks
#### Deprecations & Removals
- `runtime.GOROOT()`: Deprecated; use system path instead
- `crypto/cipher` OFB/CFB modes: Deprecated (unauthenticated encryption)
- `x509sha1` GODEBUG: Removed; SHA-1 certificates no longer verified
- Experimental `X25519Kyber768Draft00`: Removed
#### Platform Changes
- **Linux**: Now requires kernel 3.2+ (enforced)
- **macOS**: Go 1.24 is final release supporting Big Sur
- **Windows/ARM 32-bit**: Marked broken
- **WebAssembly**:
- New `go:wasmexport` directive
- Reactor/library builds supported via `-buildmode=c-shared`
#### Bootstrap Requirements
- Go 1.24 requires Go 1.22.6+ for bootstrapping
- Go 1.26 will require Go 1.24+
---
### Go 1.25 Major Changes (Released August 2025)
#### Language Changes
- No breaking language changes
- "Core types" concept removed from specification (replaced with clearer prose)
#### Tooling Improvements
**Go Command Enhancements**
- `go build -asan`: Now defaults to leak detection at program exit
- New `go.mod ignore` directive: Specify directories for go command to ignore
- `go doc -http`: Starts documentation server and opens in browser
- `go version -m -json`: Prints JSON-encoded BuildInfo structures
- Module path resolution now supports subdirectories using `<meta>` syntax
- New `work` package pattern matches all packages in work/workspace modules
- Removed automatic toolchain line additions when updating `go` version
**Vet Analyzers**
- **"waitgroup"**: Detects misplaced `sync.WaitGroup.Add` calls
- **"hostport"**: Warns against using `fmt.Sprintf` for constructing addresses
- Recommends `net.JoinHostPort` instead
#### Runtime Enhancements
**Container-Aware GOMAXPROCS**
- Linux now respects cgroup CPU bandwidth limits
- All OSes periodically update GOMAXPROCS if CPU availability changes
- Disable via environment variables or GODEBUG settings
- Critical for containerized applications
**New Garbage Collector - "Green Tea GC"**
- Experimental `GOEXPERIMENT=greenteagc` enables new GC
- **10-40% reduction in garbage collection overhead**
- Significant for GC-sensitive applications
**Trace Flight Recorder**
- New `runtime/trace.FlightRecorder` API
- Captures execution traces in in-memory ring buffer
- Essential for debugging rare events and production issues
**Other Runtime Changes**
- Simplified unhandled panic output
- VMA names on Linux identify memory purpose (debugging aid)
- New `SetDefaultGOMAXPROCS` function resets GOMAXPROCS to defaults
#### Compiler Fixes & Improvements
**Critical Nil Pointer Bug Fix**
- Fixed Go 1.21 regression where nil pointer checks were incorrectly delayed
- ⚠️ **May cause previously passing code to now panic** (correct behavior)
- Review code for assumptions about delayed nil checks
**DWARF5 Support**
- Debug information now uses DWARF version 5
- Reduces binary size and linking time
- Better debugging experience
**Faster Slices**
- Expanded stack allocation for slice backing stores
- Improved slice performance
#### Linker
- New `-funcalign=N` option specifies function entry alignment
#### Standard Library Highlights
**New Packages**
1. **`testing/synctest`** (Promoted from Experimental)
- Concurrent code testing with virtualized time
- Control time progression in tests
- Essential for testing time-dependent concurrent code
2. **`encoding/json/v2`** (Experimental)
- **Substantially better decoding performance**
- Improved API design
- Backward compatible with v1
**Major Package Updates**
| Package | Key Changes |
|---------|------------|
| `crypto` | New `MessageSigner` interface and `SignMessage` function |
| `crypto/ecdsa` | New raw key parsing/serialization functions |
| `crypto/rsa` | **Key generation now 3x faster** |
| `crypto/sha1` | **Hashing 2x faster on amd64 with SHA-NI** |
| `crypto/tls` | New `CurveID` field; SHA-1 algorithms disallowed in TLS 1.2 |
| `net` | Windows now supports file-to-connection conversion; IPv6 multicast improvements |
| `net/http` | **New `CrossOriginProtection` middleware for CSRF defense** |
| `os` | Windows async I/O support; `Root` type expanded with 12 new methods |
| `sync` | **New `WaitGroup.Go` method for convenient goroutine creation** |
| `testing` | New `Attr`, `Output` methods; `AllocsPerRun` panics with parallel tests |
| `unique` | More eager and parallel reclamation of interned values |
#### Performance Notes
**Performance Improvements**
- ECDSA and Ed25519 signing **4x faster** in FIPS 140-3 mode
- SHA3 hashing **2x faster** on Apple M processors
- AMD64 fused multiply-add instructions in v3+ mode
- ⚠️ **Changes floating-point results** (within IEEE 754 spec)
**Performance Regressions**
- SHA-1, SHA-256, SHA-512 slower without AVX2
- Most servers post-2015 support AVX2
#### Platform Changes
- **macOS**: Requires version 12 Monterey or later
- **Windows**: 32-bit windows/arm port marked for removal in Go 1.26
- **Loong64**: Race detector now supported
- **RISC-V**:
- Plugin build mode support
- New `GORISCV64=rva23u64` environment variable value
#### Deprecations
- `go/ast` functions: `FilterPackage`, `PackageExports`, `MergePackageFiles`
- `go/parser.ParseDir` function
- Old `testing/synctest` API (when `GOEXPERIMENT=synctest` set)
---
### Actionable Recommendations for This Project
#### Immediate Opportunities
1. **Replace `sync.WaitGroup` patterns with `WaitGroup.Go()`** (Go 1.25)
```go
// Old pattern
wg.Add(1)
go func() {
defer wg.Done()
// work
}()
// New pattern (Go 1.25)
wg.Go(func() {
// work
})
```
2. **Use `testing.B.Loop()` in benchmarks** (Go 1.24)
```go
// Old pattern
for i := 0; i < b.N; i++ {
// benchmark code
}
// New pattern (Go 1.24)
for b.Loop() {
// benchmark code
}
```
3. **Consider `os.Root` for file operations** (Go 1.24)
- Prevents path traversal vulnerabilities
- Safer for user-provided file paths
4. **Enable Green Tea GC for testing** (Go 1.25)
- Test with `GOEXPERIMENT=greenteagc`
- May reduce GC overhead by 10-40%
5. **Leverage container-aware GOMAXPROCS** (Go 1.25)
- No changes needed; automatic in containers
- Improves resource utilization
6. **Review floating-point operations** (Go 1.25)
- AMD64 v3+ uses FMA instructions
- May change floating-point results (within spec)
7. **Watch nil pointer checks** (Go 1.25)
- Compiler bug fix may expose latent nil pointer bugs
- Review crash reports carefully
#### Future Considerations
1. **Evaluate `encoding/json/v2`** when stable
- Better performance for JSON operations
- Currently experimental in Go 1.25
2. **Adopt tool directives** in go.mod
- Cleaner dependency management for build tools
- Remove `tools.go` if present
3. **Enable FIPS mode if required**
- Use `GOFIPS140=1` for compliance
- Performance improvements in Go 1.25 (4x faster signing)
4. **Use `runtime/trace.FlightRecorder`** for production debugging
- Capture traces of rare events
- Minimal overhead when not triggered
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage # 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
View File
@@ -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
+28 -26
View File
@@ -78,12 +78,12 @@ 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
@@ -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
@@ -441,12 +441,12 @@ The parser includes error handling for:
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
+42 -34
View File
@@ -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:
@@ -316,13 +324,13 @@ tasks:
docker:run: docker:run:
desc: Run Docker container desc: Run Docker container
aliases: [dr] aliases: [dr]
deps: ['docker:build'] deps: [docker:build]
cmds: cmds:
- docker run --rm {{.APP_NAME}}:{{.VERSION}} --help - docker run --rm {{.APP_NAME}}:{{.VERSION}} --help
docker:test: docker:test:
desc: Test Docker image desc: Test Docker image
deps: ['docker:build'] deps: [docker:build]
cmds: cmds:
- docker run --rm {{.APP_NAME}}:{{.VERSION}} --version - docker run --rm {{.APP_NAME}}:{{.VERSION}} --version
- echo "Docker image tested successfully" - echo "Docker image tested successfully"
@@ -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}}"
@@ -434,7 +442,7 @@ tasks:
docs:coverage: docs:coverage:
desc: Open coverage report in browser desc: Open coverage report in browser
deps: ['test:coverage'] deps: [test:coverage]
cmds: cmds:
- '{{if eq OS "darwin"}}open {{.COVERAGE_DIR}}/coverage.html{{else if eq OS "windows"}}start {{.COVERAGE_DIR}}/coverage.html{{else}}xdg-open {{.COVERAGE_DIR}}/coverage.html 2>/dev/null || echo "Please open {{.COVERAGE_DIR}}/coverage.html in your browser"{{end}}' - '{{if eq OS "darwin"}}open {{.COVERAGE_DIR}}/coverage.html{{else if eq OS "windows"}}start {{.COVERAGE_DIR}}/coverage.html{{else}}xdg-open {{.COVERAGE_DIR}}/coverage.html 2>/dev/null || echo "Please open {{.COVERAGE_DIR}}/coverage.html in your browser"{{end}}'
@@ -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"
+4 -4
View File
@@ -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
) )
+6 -6
View File
@@ -2,9 +2,9 @@ github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b h1:/mxSugRc4SgN7Xg
github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b/go.mod h1:ssRF0IaB1hCcKIObp3FkZOsjTcAHpgii70JelNb4H8M= github.com/fumiama/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=
+28
View File
@@ -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")
)
+50
View File
@@ -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)
}
})
}
}
+20
View File
@@ -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,
}
}
+11 -107
View File
@@ -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
View File
@@ -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
+15 -15
View File
@@ -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{
+57 -13
View File
@@ -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
}
+25 -12
View File
@@ -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,
}
} }
+14 -14
View File
@@ -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()
+18 -9
View File
@@ -9,7 +9,6 @@ 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
@@ -21,21 +20,22 @@ 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
View File
@@ -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;
} }
+8 -19
View File
@@ -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)
} }
+230 -9
View File
@@ -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 &amp; entities</p>",
Lessons: []models.Lesson{},
},
},
contains: []string{
"<!DOCTYPE html>",
"Course with",
"special",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
err := htmlExporter.WriteHTML(&buf, tt.course)
if err != nil {
t.Fatalf("WriteHTML() unexpected error: %v", err)
}
output := buf.String()
for _, want := range tt.contains {
if !strings.Contains(output, want) {
t.Errorf("WriteHTML() output missing %q", want)
}
}
})
}
}
// failingWriter is a test helper that fails after writing a certain number of bytes.
type failingWriter struct {
written int
failAfter int
}
func (w *failingWriter) Write(p []byte) (n int, err error) {
if w.written >= w.failAfter {
return 0, errWriteFailed
}
w.written += len(p)
if w.written > w.failAfter {
return 0, errWriteFailed
}
return len(p), nil
}
var errWriteFailed = &writeError{msg: "simulated write failure"}
type writeError struct {
msg string
}
func (e *writeError) Error() string {
return e.msg
}
// createTestCourseForHTML creates a test course for HTML export tests. // 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{
+95 -48
View File
@@ -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)
}
} }
} }
+22 -22
View File
@@ -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.
-11
View File
@@ -1,11 +0,0 @@
# Example Course
Course description
## Course Information
- **Course ID**:
- **Share ID**: example-id
- **Navigation Mode**:
---
+13
View File
@@ -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
}
+47
View File
@@ -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)
}
+41
View File
@@ -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"
)
+6 -3
View File
@@ -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"`
+156
View File
@@ -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)
+23 -9
View File
@@ -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)
+1 -1
View File
@@ -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 {
+60 -9
View File
@@ -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()
+13
View File
@@ -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) {
+15
View File
@@ -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
View File
@@ -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
} }
+12 -2
View File
@@ -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() {
+207
View File
@@ -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&amp;T")
f.Add("&lt;script&gt;alert('xss')&lt;/script&gt;")
f.Add("&nbsp;&nbsp;&nbsp;")
f.Add("&#39;apostrophe&#39;")
f.Add("&quot;quoted&quot;")
f.Add("&eacute;&iuml;&ouml;")
f.Add("&#x27;hex entity&#x27;")
f.Add("&#8217;numeric entity&#8217;")
// Add seed corpus with malformed/edge case HTML
f.Add("")
f.Add("plain text no HTML")
f.Add("<p>Unclosed paragraph")
f.Add("</p>Close tag first<p>")
f.Add("<div><div><div>Deep nesting")
f.Add("<script>alert('xss')</script>")
f.Add("<style>body{color:red}</style>")
f.Add("<!--comment-->")
f.Add("<!DOCTYPE html>")
f.Add("<![CDATA[data]]>")
f.Add("<p class=\"test\" id='test' data-attr=unquoted>Attributes</p>")
f.Add("<p\n\t>Whitespace in tag</p\n>")
f.Add("<>empty tag</>")
f.Add("<<<nested angles>>>")
f.Add("<p onclick=\"evil()\">Event handler</p>")
// Add seed corpus with special characters
f.Add("<p>\x00null byte</p>")
f.Add("<p>\xffhigh byte</p>")
f.Add("<p>emoji: \xf0\x9f\x98\x80</p>")
f.Add("<p>unicode: \u2603</p>")
f.Add("<p>newlines\n\r\nand\rtabs\there</p>")
// Add seed corpus with potentially problematic patterns
f.Add(string(make([]byte, 0))) // Empty
f.Add(string(make([]byte, 1))) // Single null byte
f.Add("<" + string(make([]byte, 100)) + ">") // Tag with null bytes
f.Add("<p>" + string(make([]byte, 1000)) + "</p>") // Large content
f.Fuzz(func(t *testing.T, input string) {
// Test that CleanHTML doesn't panic on any input
result := cleaner.CleanHTML(input)
// Verify the result is a valid string (doesn't panic on access)
_ = len(result)
// Test that the result can be used in common string operations without panic
_ = result == ""
_ = result == input
// Test that calling CleanHTML on its own output doesn't panic (idempotency check)
_ = cleaner.CleanHTML(result)
})
}
// FuzzHTMLCleaner_CleanHTML_Bytes tests HTML cleaner with raw byte sequences.
// This catches issues with invalid UTF-8 and binary data in HTML content.
func FuzzHTMLCleaner_CleanHTML_Bytes(f *testing.F) {
cleaner := NewHTMLCleaner()
// Add seed corpus with byte sequences
f.Add([]byte("<p>normal</p>"))
f.Add([]byte{0x3c, 0x70, 0x3e, 0xff, 0xfe, 0x3c, 0x2f, 0x70, 0x3e}) // <p>..invalid..</p>
f.Add([]byte{0x00}) // Null byte
f.Add([]byte{0xff, 0xfe}) // Invalid UTF-8
f.Add([]byte{0xef, 0xbb, 0xbf, 0x3c, 0x70, 0x3e, 0x3c, 0x2f, 0x70, 0x3e}) // UTF-8 BOM + <p></p>
f.Add([]byte("<p>\xc0\xaf</p>")) // Overlong UTF-8
f.Fuzz(func(t *testing.T, data []byte) {
// Convert bytes to string and test
input := string(data)
// Test that CleanHTML doesn't panic
result := cleaner.CleanHTML(input)
// Verify result is usable
_ = len(result)
_ = []byte(result)
})
}
+13 -3
View File
@@ -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 {
+467
View File
@@ -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",
}
}
+273
View File
@@ -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")
}
}
+12 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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