Author SHA1 Message Date
dependabot[bot] bd9aaf6b04 deps(deps): bump golang.org/x/net
Bumps the go-modules group with 1 update: [golang.org/x/net](https://github.com/golang/net).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-12 15:04:14 +00:00
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
63 changed files with 3214 additions and 1329 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"],
}],
},
}
+1 -1
View File
@@ -1,5 +1,5 @@
# These owners will be the default owners for everything in # These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence, they will # the repo. Unless a later match takes precedence, they will
# be requested for review when someone opens a pull request. # be requested for review when someone opens a pull request.
* @kjanat * @kjanat
+10 -10
View File
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our Examples of behavior that contributes to a positive environment for our
community include: community include:
- Demonstrating empathy and kindness toward other people - Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences - Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback - Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, - Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience and learning from the experience
- Focusing on what is best not just for us as individuals, but for the - Focusing on what is best not just for us as individuals, but for the
overall community overall community
Examples of unacceptable behavior include: Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or - The use of sexualized language or imagery, and sexual attention or
advances of any kind advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks - Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment - Public or private harassment
- Publishing others' private information, such as a physical or email - Publishing others' private information, such as a physical or email
address, without their explicit permission address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a - Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Enforcement Responsibilities ## Enforcement Responsibilities
+65 -65
View File
@@ -12,73 +12,73 @@ This project and everyone participating in it is governed by our Code of Conduct
Before creating bug reports, please check existing issues as you might find that the issue has already been reported. When creating a bug report, include as many details as possible: Before creating bug reports, please check existing issues as you might find that the issue has already been reported. When creating a bug report, include as many details as possible:
- Use the bug report template - Use the bug report template
- Include sample Articulate Rise content that reproduces the issue - Include sample Articulate Rise content that reproduces the issue
- Provide your environment details (OS, Go version, etc.) - Provide your environment details (OS, Go version, etc.)
- Include error messages and stack traces - Include error messages and stack traces
### Suggesting Enhancements ### Suggesting Enhancements
Enhancement suggestions are welcome! Please use the feature request template and include: Enhancement suggestions are welcome! Please use the feature request template and include:
- A clear description of the enhancement - A clear description of the enhancement
- Your use case and why this would be valuable - Your use case and why this would be valuable
- Any implementation ideas you might have - Any implementation ideas you might have
### Pull Requests ### Pull Requests
1. **Fork the repository** and create your branch from `master` 1. **Fork the repository** and create your branch from `master`
2. **Make your changes** following our coding standards 2. **Make your changes** following our coding standards
3. **Add tests** for any new functionality 3. **Add tests** for any new functionality
4. **Ensure all tests pass** by running `go test ./...` 4. **Ensure all tests pass** by running `go test ./...`
5. **Run `go fmt`** to format your code 5. **Run `go fmt`** to format your code
6. **Run `go vet`** to check for common issues 6. **Run `go vet`** to check for common issues
7. **Update documentation** if needed 7. **Update documentation** if needed
8. **Create a pull request** with a clear title and description 8. **Create a pull request** with a clear title and description
## Development Setup ## Development Setup
1. **Prerequisites:** 1. **Prerequisites:**
- Go 1.21 or later - Go 1.21 or later
- Git - Git
2. **Clone and setup:** 2. **Clone and setup:**
```bash ```bash
git clone https://github.com/your-username/articulate-parser.git git clone https://github.com/your-username/articulate-parser.git
cd articulate-parser cd articulate-parser
go mod download go mod download
``` ```
3. **Run tests:** 3. **Run tests:**
```bash ```bash
go test -v ./... go test -v ./...
``` ```
4. **Build:** 4. **Build:**
```bash ```bash
go build main.go go build main.go
``` ```
## Coding Standards ## Coding Standards
### Go Style Guide ### Go Style Guide
- Follow the [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments) - Follow the [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments)
- Use `gofmt` to format your code - Use `gofmt` to format your code
- Use meaningful variable and function names - Use meaningful variable and function names
- Add comments for exported functions and types - Add comments for exported functions and types
- Keep functions focused and small - Keep functions focused and small
### Testing ### Testing
- Write tests for new functionality - Write tests for new functionality
- Use table-driven tests where appropriate - Use table-driven tests where appropriate
- Aim for good test coverage - Aim for good test coverage
- Test error cases and edge conditions - Test error cases and edge conditions
### Commit Messages ### Commit Messages
@@ -112,19 +112,19 @@ articulate-parser/
### New Content Types ### New Content Types
1. Add the content type definition to `types/` 1. Add the content type definition to `types/`
2. Implement parsing logic in `parser/` 2. Implement parsing logic in `parser/`
3. Add export handling in `exporters/` 3. Add export handling in `exporters/`
4. Write comprehensive tests 4. Write comprehensive tests
5. Update documentation 5. Update documentation
### New Export Formats ### New Export Formats
1. Create a new exporter in `exporters/` 1. Create a new exporter in `exporters/`
2. Implement the `Exporter` interface 2. Implement the `Exporter` interface
3. Add CLI support in `main.go` 3. Add CLI support in `main.go`
4. Add tests with sample output 4. Add tests with sample output
5. Update README with usage examples 5. Update README with usage examples
## Testing ## Testing
@@ -146,31 +146,31 @@ go test -run TestSpecificFunction ./...
### Test Data ### Test Data
- Add sample Articulate Rise JSON files to `tests/data/` - Add sample Articulate Rise JSON files to `tests/data/`
- Include both simple and complex content examples - Include both simple and complex content examples
- Test edge cases and error conditions - Test edge cases and error conditions
## Documentation ## Documentation
- Update the README for user-facing changes - Update the README for user-facing changes
- Add inline code comments for complex logic - Add inline code comments for complex logic
- Update examples when adding new features - Update examples when adding new features
- Keep the feature list current - Keep the feature list current
## Release Process ## Release Process
Releases are handled by maintainers: Releases are handled by maintainers:
1. Version bumping follows semantic versioning 1. Version bumping follows semantic versioning
2. Releases are created from the `master` branch 2. Releases are created from the `master` branch
3. GitHub Actions automatically builds and publishes releases 3. GitHub Actions automatically builds and publishes releases
4. Release notes are auto-generated from commits 4. Release notes are auto-generated from commits
## Questions? ## Questions?
- Open a discussion for general questions - Open a discussion for general questions
- Use the question issue template for specific help - Use the question issue template for specific help
- Check existing issues and documentation first - Check existing issues and documentation first
## Recognition ## Recognition
+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
+20 -16
View File
@@ -5,31 +5,34 @@
## Related Issue ## Related Issue
<!-- Link to the issue this PR addresses using the syntax: Fixes #issue_number --> <!-- Link to the issue this PR addresses using the syntax: Fixes #issue_number -->
Fixes # Fixes #
## Type of Change ## Type of Change
<!-- Mark the appropriate option with an "x" --> <!-- Mark the appropriate option with an "x" -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality) - [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] New feature (non-breaking change which adds functionality)
- [ ] Documentation update - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Performance improvement - [ ] Documentation update
- [ ] Code refactoring (no functional changes) - [ ] Performance improvement
- [ ] Test updates - [ ] Code refactoring (no functional changes)
- [ ] Test updates
## Checklist ## Checklist
<!-- Mark the items you've completed with an "x" --> <!-- Mark the items you've completed with an "x" -->
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code - [ ] My code follows the style guidelines of this project
- [ ] I have added comments to complex logic - [ ] I have performed a self-review of my code
- [ ] I have updated the documentation - [ ] I have added comments to complex logic
- [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the documentation
- [ ] New and existing unit tests pass locally with my changes - [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] I have checked for potential breaking changes - [ ] New and existing unit tests pass locally with my changes
- [ ] No new warnings are generated - [ ] I have checked for potential breaking changes
- [ ] The commit message follows our guidelines - [ ] No new warnings are generated
- [ ] The commit message follows our guidelines
## Screenshots (if appropriate) ## Screenshots (if appropriate)
@@ -42,6 +45,7 @@ Fixes #
## Testing Instructions ## Testing Instructions
<!-- Provide steps to test the changes, if applicable --> <!-- Provide steps to test the changes, if applicable -->
1. 1.
2. 2.
3. 3.
+16 -16
View File
@@ -13,32 +13,32 @@ Currently, the following versions of Articulate Rise Parser are supported with s
We take the security of Articulate Rise Parser seriously. If you believe you have found a security vulnerability, please follow these steps: We take the security of Articulate Rise Parser seriously. If you believe you have found a security vulnerability, please follow these steps:
1. **Do not disclose the vulnerability publicly** - Please do not create a public GitHub issue for security vulnerabilities. 1. **Do not disclose the vulnerability publicly** - Please do not create a public GitHub issue for security vulnerabilities.
2. **Email the details to [security+articulate-parser@kjanat.com]** - Include as much information as possible about the vulnerability. 2. **Email the details to [security+articulate-parser@kjanat.com]** - Include as much information as possible about the vulnerability.
3. **Wait for a response** - We will acknowledge your email within 48 hours and provide an estimated timeline for a fix. 3. **Wait for a response** - We will acknowledge your email within 48 hours and provide an estimated timeline for a fix.
4. **Work with us** - We may ask for additional information to help us understand and address the issue. 4. **Work with us** - We may ask for additional information to help us understand and address the issue.
## What to Include in a Report ## What to Include in a Report
When reporting a vulnerability, please include: When reporting a vulnerability, please include:
- A clear description of the issue - A clear description of the issue
- Steps to reproduce the vulnerability - Steps to reproduce the vulnerability
- The potential impact of the vulnerability - The potential impact of the vulnerability
- Any possible mitigations you've identified - Any possible mitigations you've identified
## What to Expect ## What to Expect
- We will acknowledge receipt of your vulnerability report within 48 hours. - We will acknowledge receipt of your vulnerability report within 48 hours.
- We will provide regular updates about our progress. - We will provide regular updates about our progress.
- We will notify you when the vulnerability is fixed. - We will notify you when the vulnerability is fixed.
- With your permission, we will include your name in the acknowledgments. - With your permission, we will include your name in the acknowledgments.
## Security Measures ## Security Measures
This project follows these security practices: This project follows these security practices:
- Regular dependency updates via Dependabot - Regular dependency updates via Dependabot
- CodeQL security scanning - CodeQL security scanning
- Automated testing for each pull request - Automated testing for each pull request
- Code review requirements for all changes - Code review requirements for all changes
+49 -49
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"
+3 -3
View File
@@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 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
+153 -136
View File
@@ -21,8 +21,8 @@ jobs:
contents: read contents: read
pull-requests: read pull-requests: read
steps: steps:
- uses: actions/checkout@v6 - 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
@@ -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@v6 - 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
@@ -64,14 +67,18 @@ jobs:
- name: Run tests with enhanced reporting - name: Run tests with enhanced reporting
id: test id: test
env:
CGO_ENABLED: 1
run: | run: |
cat >> $GITHUB_STEP_SUMMARY << EOF {
cat << EOF
## 🔧 Test Environment ## 🔧 Test Environment
- **Go Version:** ${{ matrix.go }} - **Go Version:** $GO_VERSION
- **OS:** ubuntu-latest - **OS:** ubuntu-latest
- **Timestamp:** $(date -u) - **Timestamp:** $(date -u)
EOF EOF
} >> "$GITHUB_STEP_SUMMARY"
echo "Running tests with coverage..." echo "Running tests with coverage..."
task test:coverage 2>&1 | tee test-output.log task test:coverage 2>&1 | tee test-output.log
@@ -84,16 +91,17 @@ jobs:
SKIPPED_TESTS=$(grep -c "--- SKIP:" test-output.log || echo "0") SKIPPED_TESTS=$(grep -c "--- SKIP:" test-output.log || echo "0")
# Generate test summary # Generate test summary
cat >> $GITHUB_STEP_SUMMARY << EOF {
## 🧪 Test Results (Go ${{ matrix.go }}) cat << EOF
## 🧪 Test Results (Go $GO_VERSION)
| Metric | Value | | Metric | Value |
| ----------- | ----------------------------------------------------------- | | ----------- | ------------------------------------------------------------- |
| Total Tests | $TOTAL_TESTS | | Total Tests | $TOTAL_TESTS |
| Passed | $PASSED_TESTS | | Passed | $PASSED_TESTS |
| Failed | $FAILED_TESTS | | Failed | $FAILED_TESTS |
| Skipped | $SKIPPED_TESTS | | Skipped | $SKIPPED_TESTS |
| Status | $([ $TEST_STATUS -eq 0 ] && echo "PASSED" || echo "FAILED") | | Status | $([ "$TEST_STATUS" -eq 0 ] && echo "PASSED" || echo "FAILED") |
### 📦 Package Test Results ### 📦 Package Test Results
@@ -101,42 +109,43 @@ jobs:
|---------|--------| |---------|--------|
EOF EOF
# Extract package results # Extract package results
grep "^ok\|^FAIL" test-output.log | while read -r line; do grep "^ok\|^FAIL" test-output.log | while read -r line; do
if [[ $line == ok* ]]; then if [[ $line == ok* ]]; then
pkg=$(echo "$line" | awk '{print $2}') pkg=$(echo "$line" | awk '{print $2}')
echo "| $pkg | ✅ PASS |" >> $GITHUB_STEP_SUMMARY echo "| $pkg | ✅ PASS |"
elif [[ $line == FAIL* ]]; then elif [[ $line == FAIL* ]]; then
pkg=$(echo "$line" | awk '{print $2}') pkg=$(echo "$line" | awk '{print $2}')
echo "| $pkg | ❌ FAIL |" >> $GITHUB_STEP_SUMMARY echo "| $pkg | ❌ FAIL |"
fi fi
done done
echo "" >> $GITHUB_STEP_SUMMARY echo ""
# Add detailed results if tests failed # Add detailed results if tests failed
if [ $TEST_STATUS -ne 0 ]; then if [ "$TEST_STATUS" -ne 0 ]; then
cat >> $GITHUB_STEP_SUMMARY << 'EOF' cat << 'EOF'
### ❌ Failed Tests Details ### ❌ Failed Tests Details
``` ```
EOF EOF
grep -A 10 "--- FAIL:" test-output.log | head -100 >> $GITHUB_STEP_SUMMARY grep -A 10 -- "--- FAIL:" test-output.log | head -100
cat >> $GITHUB_STEP_SUMMARY << 'EOF' cat << 'EOF'
``` ```
EOF EOF
fi fi
} >> "$GITHUB_STEP_SUMMARY"
# Set outputs for other steps # Set outputs for other steps
cat >> $GITHUB_OUTPUT << EOF {
test-status=$TEST_STATUS echo "test-status=$TEST_STATUS"
total-tests=$TOTAL_TESTS echo "total-tests=$TOTAL_TESTS"
passed-tests=$PASSED_TESTS echo "passed-tests=$PASSED_TESTS"
failed-tests=$FAILED_TESTS echo "failed-tests=$FAILED_TESTS"
EOF } >> "$GITHUB_OUTPUT"
# Exit with the original test status # Exit with the original test status
exit $TEST_STATUS exit "$TEST_STATUS"
- name: Generate coverage report - name: Generate coverage report
if: always() if: always()
@@ -144,8 +153,9 @@ jobs:
if [ -f coverage/coverage.out ]; then if [ -f coverage/coverage.out ]; then
COVERAGE=$(go tool cover -func=coverage/coverage.out | grep total | awk '{print $3}') COVERAGE=$(go tool cover -func=coverage/coverage.out | grep total | awk '{print $3}')
cat >> $GITHUB_STEP_SUMMARY << EOF {
## 📊 Code Coverage (Go ${{ matrix.go }}) cat << EOF
## 📊 Code Coverage (Go $GO_VERSION)
**Total Coverage: $COVERAGE** **Total Coverage: $COVERAGE**
@@ -156,45 +166,46 @@ jobs:
| ------- | -------- | | ------- | -------- |
EOF EOF
# Create temporary file for package coverage aggregation # Create temporary file for package coverage aggregation
temp_coverage=$(mktemp) temp_coverage=$(mktemp)
# Extract package-level coverage data # Extract package-level coverage data
go tool cover -func=coverage/coverage.out | grep -v total | while read -r line; do go tool cover -func=coverage/coverage.out | grep -v total | while read -r line; do
if [[ $line == *".go:"* ]]; then if [[ $line == *".go:"* ]]; then
# Extract package path from file path (everything before the filename) # Extract package path from file path (everything before the filename)
filepath=$(echo "$line" | awk '{print $1}') filepath=$(echo "$line" | awk '{print $1}')
pkg_path=$(dirname "$filepath" | sed 's|github.com/kjanat/articulate-parser/||; s|^\./||') pkg_path=$(dirname "$filepath" | sed 's|github.com/kjanat/articulate-parser/||; s|^\./||')
coverage=$(echo "$line" | awk '{print $3}' | sed 's/%//') coverage=$(echo "$line" | awk '{print $3}' | sed 's/%//')
# Use root package if no subdirectory # Use root package if no subdirectory
[[ "$pkg_path" == "." || "$pkg_path" == "" ]] && pkg_path="root" [[ "$pkg_path" == "." || "$pkg_path" == "" ]] && pkg_path="root"
echo "$pkg_path $coverage" >> "$temp_coverage" echo "$pkg_path $coverage" >> "$temp_coverage"
fi fi
done done
# Aggregate coverage by package (average) # Aggregate coverage by package (average)
awk '{ awk '{
packages[$1] += $2 packages[$1] += $2
counts[$1]++ counts[$1]++
}
END {
for (pkg in packages) {
avg = packages[pkg] / counts[pkg]
printf "| %s | %.1f%% |\n", pkg, avg
} }
}' "$temp_coverage" | sort >> $GITHUB_STEP_SUMMARY END {
for (pkg in packages) {
avg = packages[pkg] / counts[pkg]
printf "| %s | %.1f%% |\n", pkg, avg
}
}' "$temp_coverage" | sort
rm -f "$temp_coverage" rm -f "$temp_coverage"
cat >> $GITHUB_STEP_SUMMARY << 'EOF' cat << 'EOF'
</details> </details>
EOF EOF
} >> "$GITHUB_STEP_SUMMARY"
else else
cat >> $GITHUB_STEP_SUMMARY << 'EOF' cat >> "$GITHUB_STEP_SUMMARY" << 'EOF'
## ⚠️ Coverage Report ## ⚠️ Coverage Report
No coverage file generated No coverage file generated
@@ -203,9 +214,9 @@ jobs:
- name: Upload test artifacts - name: Upload test artifacts
if: failure() if: failure()
uses: actions/upload-artifact@v6 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/
@@ -213,54 +224,56 @@ jobs:
- name: Run linters - name: Run linters
run: | run: |
# Initialize summary {
cat >> $GITHUB_STEP_SUMMARY << EOF cat << EOF
## 🔍 Static Analysis (Go ${{ matrix.go }}) ## 🔍 Static Analysis (Go $GO_VERSION)
EOF EOF
# Run go vet # Run go vet
VET_OUTPUT=$(task lint:vet 2>&1 || echo "") VET_OUTPUT=$(task lint:vet 2>&1 || echo "")
VET_STATUS=$? VET_STATUS=$?
if [ $VET_STATUS -eq 0 ]; then if [ "$VET_STATUS" -eq 0 ]; then
echo "✅ **go vet:** No issues found" >> $GITHUB_STEP_SUMMARY echo "✅ **go vet:** No issues found"
else else
cat >> $GITHUB_STEP_SUMMARY << 'EOF' cat << 'EOF'
❌ **go vet:** Issues found ❌ **go vet:** Issues found
``` ```
EOF EOF
echo "$VET_OUTPUT" >> $GITHUB_STEP_SUMMARY echo "$VET_OUTPUT"
echo '```' >> $GITHUB_STEP_SUMMARY echo '```'
fi fi
echo "" >> $GITHUB_STEP_SUMMARY echo ""
# Run go fmt check # Run go fmt check
FMT_OUTPUT=$(task lint:fmt 2>&1 || echo "") FMT_OUTPUT=$(task lint:fmt 2>&1 || echo "")
FMT_STATUS=$? FMT_STATUS=$?
if [ $FMT_STATUS -eq 0 ]; then if [ "$FMT_STATUS" -eq 0 ]; then
echo "✅ **go fmt:** All files properly formatted" >> $GITHUB_STEP_SUMMARY echo "✅ **go fmt:** All files properly formatted"
else else
cat >> $GITHUB_STEP_SUMMARY << 'EOF' cat << 'EOF'
❌ **go fmt:** Files need formatting ❌ **go fmt:** Files need formatting
``` ```
EOF EOF
echo "$FMT_OUTPUT" >> $GITHUB_STEP_SUMMARY echo "$FMT_OUTPUT"
echo '```' >> $GITHUB_STEP_SUMMARY echo '```'
fi fi
} >> "$GITHUB_STEP_SUMMARY"
# Exit with error if any linter failed # Exit with error if any linter failed
[ $VET_STATUS -eq 0 ] && [ $FMT_STATUS -eq 0 ] || exit 1 [ "$VET_STATUS" -eq 0 ] && [ "$FMT_STATUS" -eq 0 ] || exit 1
- name: Job Summary - name: Job Summary
if: always() if: always()
run: | run: |
cat >> $GITHUB_STEP_SUMMARY << 'EOF' {
## 📋 Job Summary (Go ${{ matrix.go }}) echo "## 📋 Job Summary (Go $GO_VERSION)"
echo ""
cat << 'EOF'
| Step | Status | | Step | Status |
| --------------- | --------------------------------------------------------------- | | --------------- | --------------------------------------------------------------- |
| Dependencies | Success | | Dependencies | Success |
@@ -270,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 }}
@@ -283,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:
@@ -294,56 +308,59 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
- name: Install Task - name: Install Task
uses: go-task/setup-task@v1 uses: go-task/setup-task@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Build Docker image using Task - name: Build Docker image using Task
run: task docker:build run: task docker:build
- name: Test Docker image using Task - name: Test Docker image using Task
run: | run: |
cat >> $GITHUB_STEP_SUMMARY << 'EOF' {
cat << 'EOF'
## 🧪 Docker Image Tests ## 🧪 Docker Image Tests
EOF EOF
# Run Task docker test # Run Task docker test
task docker:test task docker:test
echo "**Testing help command:**" >> $GITHUB_STEP_SUMMARY echo "**Testing help command:**"
echo '```terminaloutput' >> $GITHUB_STEP_SUMMARY echo '```terminaloutput'
docker run --rm articulate-parser:latest --help >> $GITHUB_STEP_SUMMARY docker run --rm articulate-parser:latest --help
echo '```' >> $GITHUB_STEP_SUMMARY echo '```'
echo "" >> $GITHUB_STEP_SUMMARY echo ""
# Test image size # Test image size
IMAGE_SIZE=$(docker image inspect articulate-parser:latest --format='{{.Size}}' | numfmt --to=iec-i --suffix=B) IMAGE_SIZE=$(docker image inspect articulate-parser:latest --format='{{.Size}}' | numfmt --to=iec-i --suffix=B)
echo "**Image size:** $IMAGE_SIZE" >> $GITHUB_STEP_SUMMARY echo "**Image size:** $IMAGE_SIZE"
echo "" >> $GITHUB_STEP_SUMMARY echo ""
} >> "$GITHUB_STEP_SUMMARY"
dependency-review: dependency-review:
name: Dependency Review name: Dependency Review
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
pull-requests: write
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
steps: steps:
- name: "Checkout Repository" - name: "Checkout Repository"
uses: actions/checkout@v6 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
@@ -354,37 +371,37 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
needs: [test, docker-test, dependency-review] needs: [test, docker-test]
if: | if: |
github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.event_name == 'push' && (github.ref == 'refs/heads/master' ||
github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/develop' ||
startsWith(github.ref, 'refs/heads/feature/docker')) startsWith(github.ref, 'refs/heads/feature/docker'))
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 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 }}
@@ -405,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.
@@ -433,7 +450,7 @@ jobs:
- name: Generate Docker summary - name: Generate Docker summary
run: | run: |
cat >> $GITHUB_STEP_SUMMARY << 'EOF' cat >> "$GITHUB_STEP_SUMMARY" << 'EOF'
## 🐳 Docker Build Summary ## 🐳 Docker Build Summary
**Image:** `ghcr.io/${{ github.repository }}` **Image:** `ghcr.io/${{ github.repository }}`
+104 -104
View File
@@ -1,104 +1,104 @@
# For most projects, this workflow file will not need changing; you simply need # For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository. # to commit it to your repository.
# #
# You may wish to alter this file to override the set of languages analyzed, # You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic. # or to provide custom queries or build logic.
# #
# ******** NOTE ******** # ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check # We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of # the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages. # supported CodeQL languages.
# #
name: "CodeQL" name: "CodeQL"
# This workflow is configured to be called by other workflows for more controlled execution # This workflow is configured to be called by other workflows for more controlled execution
# This allows integration with the main CI pipeline and avoids redundant runs # This allows integration with the main CI pipeline and avoids redundant runs
# To enable automatic execution, uncomment the triggers below: # To enable automatic execution, uncomment the triggers below:
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:
# branches: [ "master" ] # branches: [ "master" ]
jobs: jobs:
analyze: analyze:
name: Analyze (${{ matrix.language }}) name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see: # Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql # - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources # - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only) # - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements. # Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions: permissions:
# required for all workflows # required for all workflows
security-events: write security-events: write
# required to fetch internal or private CodeQL packs # required to fetch internal or private CodeQL packs
packages: read packages: read
# only required for workflows in private repositories # only required for workflows in private repositories
actions: read actions: read
contents: read contents: read
strategy: strategy:
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@v6 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@v6 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@v6 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@v6 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: |
+81 -79
View File
@@ -1,79 +1,81 @@
# Created by https://www.toptal.com/developers/gitignore/api/go # Created by https://www.toptal.com/developers/gitignore/api/go
# Edit at https://www.toptal.com/developers/gitignore?templates=go # Edit at https://www.toptal.com/developers/gitignore?templates=go
### Go ### ### Go ###
# If you prefer the allow list template instead of the deny list, see community template: # If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
# #
# Binaries for programs and plugins # Binaries for programs and plugins
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll
*.so *.so
*.dylib *.dylib
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
# Output of the go coverage tool, specifically when used with LiteIDE # Output of the go coverage tool, specifically when used with LiteIDE
*.out *.out
# Dependency directories (remove the comment below to include it) # Dependency directories (remove the comment below to include it)
# vendor/ # vendor/
# Go workspace file # Go workspace file
go.work go.work
# End of https://www.toptal.com/developers/gitignore/api/go # End of https://www.toptal.com/developers/gitignore/api/go
# Shit # Shit
.github/TODO .github/TODO
# Local test files # Local test files
output/ output/
outputs/ outputs/
articulate-sample.json articulate-sample.json
test-output.* test-output.*
go-os-arch-matrix.csv # Example test exports (defensive; tests now write to a temp dir)
test_godocx.go internal/exporters/output.*
test_input.json go-os-arch-matrix.csv
test_godocx.go
# Build artifacts test_input.json
build/
# Build artifacts
# Old workflows build/
.github/workflows/ci-old.yml
.github/workflows/ci-enhanced.yml # Old workflows
.github/workflows/ci-old.yml
# Test coverage files .github/workflows/ci-enhanced.yml
coverage.out
coverage.txt # Test coverage files
coverage.html coverage.out
coverage.* coverage.txt
coverage coverage.html
*.cover coverage.*
*.coverprofile coverage
main_coverage *.cover
*.coverprofile
# Other common exclusions main_coverage
*.exe
*.exe~ # Other common exclusions
*.dll *.exe
*.so *.exe~
*.dylib *.dll
*.test *.so
*.out *.dylib
/tmp/ *.test
.github/copilot-instructions.md *.out
/tmp/
# Editors .github/copilot-instructions.md
.vscode/
.idea/ # Editors
.vscode/
.task/ .idea/
**/*.local.* .task/
.claude/ **/*.local.*
NUL .claude/
NUL
+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"],
},
},
}
+75
View File
@@ -0,0 +1,75 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
# File quality
- id: trailing-whitespace
exclude: '^\.github/ISSUE_TEMPLATE/.*\.yml$'
- id: end-of-file-fixer
- id: mixed-line-ending
args: ["--fix=lf"]
# File validation
- id: check-yaml
- id: check-json
- id: check-toml
# Security
- id: detect-private-key
# Git safety
- id: check-merge-conflict
- id: check-case-conflict
- id: no-commit-to-branch
args: ["--branch=master", "--branch=main"]
# File structure
- id: check-added-large-files
- id: check-symlinks
- id: check-executables-have-shebangs
- repo: local
hooks:
- id: actionlint
name: Lint GitHub Actions workflow files
description: Runs actionlint to lint GitHub Actions workflow files
language: golang
types: ["yaml"]
files: ^\.github/workflows/
entry: actionlint
minimum_pre_commit_version: 3.0.0
- repo: https://github.com/golangci/golangci-lint
rev: v2.7.2
hooks:
- id: golangci-lint
name: golangci-lint
description: Fast linters runner for Go. Note that only modified files are linted, so linters like 'unused' that need to scan all files won't work as expected.
entry: golangci-lint run --new-from-rev HEAD --fix
types: [go]
language: golang
require_serial: true
pass_filenames: false
# - id: golangci-lint-full
# name: golangci-lint-full
# description: Fast linters runner for Go. Runs on all files in the module. Use this hook if you use pre-commit in CI.
# entry: golangci-lint run --fix
# types: [go]
# language: golang
# require_serial: true
# pass_filenames: false
- id: golangci-lint-fmt
name: golangci-lint-fmt
description: Fast linters runner for Go. Formats all files in the repo.
entry: golangci-lint fmt
types: [go]
language: golang
require_serial: true
pass_filenames: false
- id: golangci-lint-config-verify
name: golangci-lint-config-verify
description: Verifies the configuration file
entry: golangci-lint config verify
files: '\.golangci\.(?:yml|yaml|toml|json)'
language: golang
pass_filenames: false
+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"
]
}
}
}
}
+5
View File
@@ -2,6 +2,11 @@
A Go CLI tool that parses Articulate Rise courses from URLs or local JSON files and exports them to Markdown, HTML, or DOCX formats. 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
### Primary Commands (using Taskfile) ### Primary Commands (using Taskfile)
+11 -11
View File
@@ -49,17 +49,17 @@ docker run --rm ghcr.io/kjanat/articulate-parser:latest --version
## Available Tags ## Available Tags
- `latest` - Latest stable release - `latest` - Latest stable release
- `v1.x.x` - Specific version tags - `v1.x.x` - Specific version tags
- `main` - Latest development build - `main` - Latest development build
## Image Details ## Image Details
- **Base Image**: `scratch` (minimal attack surface) - **Base Image**: `scratch` (minimal attack surface)
- **Architecture**: Multi-arch support (amd64, arm64) - **Architecture**: Multi-arch support (amd64, arm64)
- **Size**: < 10MB (optimized binary) - **Size**: < 10MB (optimized binary)
- **Security**: Runs as non-root user - **Security**: Runs as non-root user
- **Features**: SBOM and provenance attestation included - **Features**: SBOM and provenance attestation included
## Development ## Development
@@ -77,6 +77,6 @@ docker-compose up --build
## Repository ## Repository
- **Source**: [github.com/kjanat/articulate-parser](https://github.com/kjanat/articulate-parser) - **Source**: [github.com/kjanat/articulate-parser](https://github.com/kjanat/articulate-parser)
- **Issues**: [Report bugs or request features](https://github.com/kjanat/articulate-parser/issues) - **Issues**: [Report bugs or request features](https://github.com/kjanat/articulate-parser/issues)
- **License**: See repository for license details - **License**: See repository for license details
+2 -2
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
@@ -27,7 +27,7 @@ ARG BUILD_TIME
ARG GIT_COMMIT ARG GIT_COMMIT
# Docker buildx automatically provides these for multi-platform builds # Docker buildx automatically provides these for multi-platform builds
ARG BUILDPLATFORM ARG BUILDPLATFORM
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT
+2 -2
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
@@ -30,7 +30,7 @@ ARG BUILD_TIME
ARG GIT_COMMIT ARG GIT_COMMIT
# Docker buildx automatically provides these for multi-platform builds # Docker buildx automatically provides these for multi-platform builds
ARG BUILDPLATFORM ARG BUILDPLATFORM
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT
+109 -107
View File
@@ -20,36 +20,36 @@ A Go-based parser that converts Articulate Rise e-learning content to various fo
flowchart TD flowchart TD
%% User Input %% User Input
CLI[Command Line Interface<br/>main.go] --> APP{App Service<br/>services/app.go} CLI[Command Line Interface<br/>main.go] --> APP{App Service<br/>services/app.go}
%% Core Application Logic %% Core Application Logic
APP --> |"ProcessCourseFromURI"| PARSER[Course Parser<br/>services/parser.go] APP --> |"ProcessCourseFromURI"| PARSER[Course Parser<br/>services/parser.go]
APP --> |"ProcessCourseFromFile"| PARSER APP --> |"ProcessCourseFromFile"| PARSER
APP --> |"exportCourse"| FACTORY[Exporter Factory<br/>exporters/factory.go] APP --> |"exportCourse"| FACTORY[Exporter Factory<br/>exporters/factory.go]
%% Data Sources %% Data Sources
PARSER --> |"FetchCourse"| API[Articulate Rise API<br/>rise.articulate.com] PARSER --> |"FetchCourse"| API[Articulate Rise API<br/>rise.articulate.com]
PARSER --> |"LoadCourseFromFile"| FILE[Local JSON File<br/>*.json] PARSER --> |"LoadCourseFromFile"| FILE[Local JSON File<br/>*.json]
%% Data Models %% Data Models
API --> MODELS[Data Models<br/>models/course.go] API --> MODELS[Data Models<br/>models/course.go]
FILE --> MODELS FILE --> MODELS
MODELS --> |Course, Lesson, Item| APP MODELS --> |Course, Lesson, Item| APP
%% Export Factory Pattern %% Export Factory Pattern
FACTORY --> |"CreateExporter"| MARKDOWN[Markdown Exporter<br/>exporters/markdown.go] FACTORY --> |"CreateExporter"| MARKDOWN[Markdown Exporter<br/>exporters/markdown.go]
FACTORY --> |"CreateExporter"| HTML[HTML Exporter<br/>exporters/html.go] FACTORY --> |"CreateExporter"| HTML[HTML Exporter<br/>exporters/html.go]
FACTORY --> |"CreateExporter"| DOCX[DOCX Exporter<br/>exporters/docx.go] FACTORY --> |"CreateExporter"| DOCX[DOCX Exporter<br/>exporters/docx.go]
%% HTML Cleaning Service %% HTML Cleaning Service
CLEANER[HTML Cleaner<br/>services/html_cleaner.go] --> MARKDOWN CLEANER[HTML Cleaner<br/>services/html_cleaner.go] --> MARKDOWN
CLEANER --> HTML CLEANER --> HTML
CLEANER --> DOCX CLEANER --> DOCX
%% Output Files %% Output Files
MARKDOWN --> |"Export"| MD_OUT[Markdown Files<br/>*.md] MARKDOWN --> |"Export"| MD_OUT[Markdown Files<br/>*.md]
HTML --> |"Export"| HTML_OUT[HTML Files<br/>*.html] HTML --> |"Export"| HTML_OUT[HTML Files<br/>*.html]
DOCX --> |"Export"| DOCX_OUT[Word Documents<br/>*.docx] DOCX --> |"Export"| DOCX_OUT[Word Documents<br/>*.docx]
%% Interfaces (Contracts) %% Interfaces (Contracts)
IPARSER[CourseParser Interface<br/>interfaces/parser.go] -.-> PARSER IPARSER[CourseParser Interface<br/>interfaces/parser.go] -.-> PARSER
IEXPORTER[Exporter Interface<br/>interfaces/exporter.go] -.-> MARKDOWN IEXPORTER[Exporter Interface<br/>interfaces/exporter.go] -.-> MARKDOWN
@@ -64,7 +64,7 @@ flowchart TD
classDef output fill:#fce7f3,stroke:#be185d,stroke-width:2px,color:#be185d classDef output fill:#fce7f3,stroke:#be185d,stroke-width:2px,color:#be185d
classDef interface fill:#ecfdf5,stroke:#16a34a,stroke-width:1px,stroke-dasharray: 5 5,color:#16a34a classDef interface fill:#ecfdf5,stroke:#16a34a,stroke-width:1px,stroke-dasharray: 5 5,color:#16a34a
classDef service fill:#cffafe,stroke:#0891b2,stroke-width:2px,color:#0891b2 classDef service fill:#cffafe,stroke:#0891b2,stroke-width:2px,color:#0891b2
class CLI userInput class CLI userInput
class APP,FACTORY coreLogic class APP,FACTORY coreLogic
class API,FILE,MODELS dataSource class API,FILE,MODELS dataSource
@@ -78,32 +78,32 @@ flowchart TD
The system follows **Clean Architecture** principles with clear separation of concerns: The system follows **Clean Architecture** principles with clear separation of concerns:
- **🎯 Entry Point**: Command-line interface handles user input and coordinates operations - **Entry Point**: Command-line interface handles user input and coordinates operations
- **🏗️ Application Layer**: Core business logic with dependency injection - **Application Layer**: Core business logic with dependency injection
- **📋 Interface Layer**: Contracts defining behavior without implementation details - **Interface Layer**: Contracts defining behavior without implementation details
- **🔧 Service Layer**: Concrete implementations of parsing and utility services - **Service Layer**: Concrete implementations of parsing and utility services
- **📤 Export Layer**: Factory pattern for format-specific exporters - **Export Layer**: Factory pattern for format-specific exporters
- **📊 Data Layer**: Domain models representing course structure - **Data Layer**: Domain models representing course structure
## Features ## Features
- Parse Articulate Rise JSON data from URLs or local files - Parse Articulate Rise JSON data from URLs or local files
- Export to Markdown (.md) format - Export to Markdown (.md) format
- Export to HTML (.html) format with professional styling - Export to HTML (.html) format with professional styling
- Export to Word Document (.docx) format - Export to Word Document (.docx) format
- Support for various content types: - Support for various content types:
- Text content with headings and paragraphs - Text content with headings and paragraphs
- Lists and bullet points - Lists and bullet points
- Multimedia content (videos and images) - Multimedia content (videos and images)
- Knowledge checks and quizzes - Knowledge checks and quizzes
- Interactive content (flashcards) - Interactive content (flashcards)
- Course structure and metadata - Course structure and metadata
## Installation ## Installation
### Prerequisites ### Prerequisites
- Go, I don't know the version, but I have [![Go version](https://img.shields.io/github/go-mod/go-version/kjanat/articulate-parser?label=)][gomod] configured right now, and it works, see the [CI][Build] workflow where it is tested. - Go, I don't know the version, but I have [![Go version](https://img.shields.io/github/go-mod/go-version/kjanat/articulate-parser?label=)][gomod] configured right now, and it works, see the [CI][Build] workflow where it is tested.
### Install from source ### Install from source
@@ -124,7 +124,7 @@ go install github.com/kjanat/articulate-parser@latest
The parser uses the following external library: The parser uses the following external library:
- `github.com/fumiama/go-docx` - For creating Word documents (MIT license) - `github.com/fumiama/go-docx` - For creating Word documents (MIT license)
## Testing ## Testing
@@ -164,25 +164,25 @@ go run main.go <input_uri_or_file> <output_format> [output_path]
#### Examples #### Examples
1. **Parse from URL and export to Markdown:** 1. **Parse from URL and export to Markdown:**
```bash ```bash
go run main.go "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/" md go run main.go "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/" md
``` ```
2. **Parse from local file and export to Word:** 2. **Parse from local file and export to Word:**
```bash ```bash
go run main.go "articulate-sample.json" docx "my-course.docx" go run main.go "articulate-sample.json" docx "my-course.docx"
``` ```
3. **Parse from local file and export to HTML:** 3. **Parse from local file and export to HTML:**
```bash ```bash
go run main.go "articulate-sample.json" html "output.html" go run main.go "articulate-sample.json" html "output.html"
``` ```
4. **Parse from local file and export to Markdown:** 4. **Parse from local file and export to Markdown:**
```bash ```bash
go run main.go "articulate-sample.json" md "output.md" go run main.go "articulate-sample.json" md "output.md"
@@ -206,7 +206,7 @@ Then run:
The application is available as a Docker image from GitHub Container Registry. The application is available as a Docker image from GitHub Container Registry.
### 🐳 Docker Image Information ### Docker Image Information
- **Registry**: `ghcr.io/kjanat/articulate-parser` - **Registry**: `ghcr.io/kjanat/articulate-parser`
- **Platforms**: linux/amd64, linux/arm64 - **Platforms**: linux/amd64, linux/arm64
@@ -225,14 +225,14 @@ docker run --rm ghcr.io/kjanat/articulate-parser:latest --help
### Available Tags ### Available Tags
| Tag | Description | Use Case | | Tag | Description | Use Case |
|-----|-------------|----------| | --------------------- | ------------------------------------------- | ---------------------- |
| `latest` | Latest stable release from master branch | Production use | | `latest` | Latest stable release from master branch | Production use |
| `edge` | Latest development build from master branch | Testing new features | | `edge` | Latest development build from master branch | Testing new features |
| `v1.x.x` | Specific version releases | Production pinning | | `v1.x.x` | Specific version releases | Production pinning |
| `develop` | Development branch builds | Development/testing | | `develop` | Development branch builds | Development/testing |
| `feature/docker-ghcr` | Feature branch builds | Feature testing | | `feature/docker-ghcr` | Feature branch builds | Feature testing |
| `master` | Latest master branch build | Continuous integration | | `master` | Latest master branch build | Continuous integration |
### Usage Examples ### Usage Examples
@@ -313,11 +313,11 @@ docker build --build-arg VERSION=local --build-arg BUILD_TIME=$(date -u +%Y-%m-%
The Docker image supports the following build-time arguments: The Docker image supports the following build-time arguments:
| Argument | Description | Default | | Argument | Description | Default |
|----------|-------------|---------| | ------------ | ------------------------------------- | -------------- |
| `VERSION` | Version string embedded in the binary | `dev` | | `VERSION` | Version string embedded in the binary | `dev` |
| `BUILD_TIME` | Build timestamp | Current time | | `BUILD_TIME` | Build timestamp | Current time |
| `GIT_COMMIT` | Git commit hash | Current commit | | `GIT_COMMIT` | Git commit hash | Current commit |
### Docker Security ### Docker Security
@@ -332,88 +332,88 @@ The Docker image supports the following build-time arguments:
The project maintains high code quality standards: The project maintains high code quality standards:
- Cyclomatic complexity ≤ 15 (checked with [gocyclo](https://github.com/fzipp/gocyclo)) - Cyclomatic complexity ≤ 15 (checked with [gocyclo](https://github.com/fzipp/gocyclo))
- Race condition detection enabled - Race condition detection enabled
- Comprehensive test coverage - Comprehensive test coverage
- Code formatting with `gofmt` - Code formatting with `gofmt`
- Static analysis with `go vet` - Static analysis with `go vet`
### Contributing ### Contributing
1. Fork the repository 1. Fork the repository
2. Create a feature branch 2. Create a feature branch
3. Make your changes 3. Make your changes
4. Run tests: `go test ./...` 4. Run tests: `go test ./...`
5. Submit a pull request 5. Submit a pull request
## Output Formats ## Output Formats
### Markdown (`.md`) ### Markdown (`.md`)
- Hierarchical structure with proper heading levels - Hierarchical structure with proper heading levels
- Clean text content with HTML tags removed - Clean text content with HTML tags removed
- Lists and bullet points preserved - Lists and bullet points preserved
- Quiz questions with correct answers marked - Quiz questions with correct answers marked
- Media references included - Media references included
- Course metadata at the top - Course metadata at the top
### HTML (`.html`) ### HTML (`.html`)
- Professional styling with embedded CSS - Professional styling with embedded CSS
- Interactive and visually appealing layout - Interactive and visually appealing layout
- Proper HTML structure with semantic elements - Proper HTML structure with semantic elements
- Responsive design for different screen sizes - Responsive design for different screen sizes
- All content types beautifully formatted - All content types beautifully formatted
- Maintains course hierarchy and organization - Maintains course hierarchy and organization
### Word Document (`.docx`) ### Word Document (`.docx`)
- Professional document formatting - Professional document formatting
- Bold headings and proper typography - Bold headings and proper typography
- Bulleted lists - Bulleted lists
- Quiz questions with answers - Quiz questions with answers
- Media content references - Media content references
- Maintains course structure - Maintains course structure
## Supported Content Types ## Supported Content Types
The parser handles the following Articulate Rise content types: The parser handles the following Articulate Rise content types:
- **Text blocks**: Headings and paragraphs - **Text blocks**: Headings and paragraphs
- **Lists**: Bullet points and numbered lists - **Lists**: Bullet points and numbered lists
- **Multimedia**: Videos and images (references only) - **Multimedia**: Videos and images (references only)
- **Knowledge Checks**: Multiple choice, multiple response, fill-in-the-blank, matching - **Knowledge Checks**: Multiple choice, multiple response, fill-in-the-blank, matching
- **Interactive Content**: Flashcards and interactive scenarios - **Interactive Content**: Flashcards and interactive scenarios
- **Dividers**: Section breaks - **Dividers**: Section breaks
- **Sections**: Course organization - **Sections**: Course organization
## Data Structure ## Data Structure
The parser works with the standard Articulate Rise JSON format which includes: The parser works with the standard Articulate Rise JSON format which includes:
- Course metadata (title, description, settings) - Course metadata (title, description, settings)
- Lesson structure - Lesson structure
- Content items with various types - Content items with various types
- Media references - Media references
- Quiz/assessment data - Quiz/assessment data
- Styling and layout information - Styling and layout information
## URL Pattern Recognition ## URL Pattern Recognition
The parser automatically extracts share IDs from Articulate Rise URLs: The parser automatically extracts share IDs from Articulate Rise URLs:
- Input: `https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/` - Input: `https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/`
- API URL: `https://rise.articulate.com/api/rise-runtime/boot/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO` - API URL: `https://rise.articulate.com/api/rise-runtime/boot/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO`
## Error Handling ## Error Handling
The parser includes error handling for: The parser includes error handling for:
- Invalid URLs or share IDs - Invalid URLs or share IDs
- Network connection issues - Network connection issues
- Malformed JSON data - Malformed JSON data
- File I/O errors - File I/O errors
- Unsupported content types - Unsupported content types
<!-- ## Code coverage <!-- ## Code coverage
@@ -425,28 +425,28 @@ The parser includes error handling for:
## Limitations ## Limitations
- Media files (videos, images) are referenced but not downloaded - Media files (videos, images) are referenced but not downloaded
- Complex interactive elements may be simplified in export - Complex interactive elements may be simplified in export
- Styling and visual formatting is not preserved - Styling and visual formatting is not preserved
- Assessment logic and interactivity is lost in static exports - Assessment logic and interactivity is lost in static exports
## Performance ## Performance
- Lightweight with minimal dependencies - Lightweight with minimal dependencies
- Fast JSON parsing and export - Fast JSON parsing and export
- Memory efficient processing - Memory efficient processing
- No external license requirements - No external license requirements
## Future Enhancements ## Future Enhancements
Potential improvements could include: Potential improvements could include:
- [ ] PDF export support - [ ] PDF export support
- [ ] Media file downloading - [ ] Media file downloading
- [x] ~~HTML export with preserved styling~~ - [x] ~~HTML export with preserved styling~~
- [ ] SCORM package support - [ ] SCORM package support
- [ ] Batch processing capabilities - [ ] Batch processing capabilities
- [ ] Custom template support - [ ] Custom template support
## License ## License
@@ -460,7 +460,9 @@ This is a utility tool for educational content conversion. Please ensure you hav
[Go report]: https://goreportcard.com/report/github.com/kjanat/articulate-parser [Go report]: https://goreportcard.com/report/github.com/kjanat/articulate-parser
[gomod]: go.mod [gomod]: go.mod
[Issues]: https://github.com/kjanat/articulate-parser/issues [Issues]: https://github.com/kjanat/articulate-parser/issues
<!-- [Latest release]: https://github.com/kjanat/articulate-parser/releases/latest --> <!-- [Latest release]: https://github.com/kjanat/articulate-parser/releases/latest -->
[MIT License]: LICENSE [MIT License]: LICENSE
[Package documentation]: https://godoc.org/github.com/kjanat/articulate-parser [Package documentation]: https://godoc.org/github.com/kjanat/articulate-parser
[Tags]: https://github.com/kjanat/articulate-parser/tags [Tags]: https://github.com/kjanat/articulate-parser/tags
+4 -6
View File
@@ -1,16 +1,14 @@
module github.com/kjanat/articulate-parser module github.com/kjanat/articulate-parser
go 1.24.0 go 1.26.0
toolchain go1.25.5
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.48.0 golang.org/x/net v0.60.0
golang.org/x/text v0.32.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.34.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.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 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()
+19 -10
View File
@@ -9,33 +9,33 @@ import (
"github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
) )
//go:embed html_styles.css //go:embed html_styles.css
var defaultCSS string var defaultCSS string
//go:embed html_template.html //go:embed html_template.gohtml
var htmlTemplate string var htmlTemplate string
// HTMLExporter implements the Exporter interface for HTML format. // HTMLExporter implements the Exporter interface for HTML format.
// It converts Articulate Rise course data into a structured HTML document using templates. // It converts Articulate Rise course data into a structured HTML document using templates.
type HTMLExporter struct { type HTMLExporter struct {
// htmlCleaner is used to convert HTML content to plain text when needed baseExporter
htmlCleaner *services.HTMLCleaner // tmpl holds the parsed HTML template.
// tmpl holds the parsed HTML template
tmpl *template.Template tmpl *template.Template
} }
// NewHTMLExporter creates a new HTMLExporter instance. // NewHTMLExporter creates a new HTMLExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed. // It takes an HTMLCleaner to handle HTML content conversion when plain text is needed
// and a logger for logging.
// //
// Parameters: // Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data // - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for warnings and errors
// //
// Returns: // Returns:
// - An implementation of the Exporter interface for HTML format // - An implementation of the Exporter interface for HTML format
func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter { func NewHTMLExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
// Parse the template with custom functions // Parse the template with custom functions
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"safeHTML": func(s string) template.HTML { "safeHTML": func(s string) template.HTML {
@@ -49,8 +49,8 @@ func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
tmpl := template.Must(template.New("html").Funcs(funcMap).Parse(htmlTemplate)) tmpl := template.Must(template.New("html").Funcs(funcMap).Parse(htmlTemplate))
return &HTMLExporter{ return &HTMLExporter{
htmlCleaner: htmlCleaner, baseExporter: newBaseExporter(htmlCleaner, logger),
tmpl: tmpl, tmpl: tmpl,
} }
} }
@@ -69,7 +69,16 @@ func (e *HTMLExporter) Export(course *models.Course, outputPath string) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to create file: %w", err) return fmt.Errorf("failed to create file: %w", err)
} }
defer f.Close() defer func() {
// Close errors are logged but not fatal since the content has already been written.
// The file must be closed to flush buffers, but a close error doesn't invalidate
// the data already written to disk.
if closeErr := f.Close(); closeErr != nil {
// Note: In production, this should log via a logger passed to the exporter.
// For now, we silently ignore close errors as they're non-fatal.
_ = closeErr
}
}()
return e.WriteHTML(f, course) return e.WriteHTML(f, course)
} }
+109 -107
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.
-12
View File
@@ -1,12 +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.
+9 -9
View File
@@ -137,7 +137,7 @@ try {
# Show targets and exit if requested # Show targets and exit if requested
if ($ShowTargets) { if ($ShowTargets) {
Write-Host 'Available build targets:' -ForegroundColor Cyan Write-Host 'Available build targets:' -ForegroundColor Cyan
# Get available platforms and architectures from Go toolchain # Get available platforms and architectures from Go toolchain
try { try {
$GoTargets = @(go tool dist list 2>$null) $GoTargets = @(go tool dist list 2>$null)
@@ -148,7 +148,7 @@ try {
Write-Host '⚠️ Could not retrieve targets from Go. Using default targets.' -ForegroundColor Yellow Write-Host '⚠️ Could not retrieve targets from Go. Using default targets.' -ForegroundColor Yellow
$PlatformList = $Platforms.Split(',') | ForEach-Object { $_.Trim() } $PlatformList = $Platforms.Split(',') | ForEach-Object { $_.Trim() }
$ArchList = $Architectures.Split(',') | ForEach-Object { $_.Trim() } $ArchList = $Architectures.Split(',') | ForEach-Object { $_.Trim() }
foreach ($platform in $PlatformList) { foreach ($platform in $PlatformList) {
foreach ($arch in $ArchList) { foreach ($arch in $ArchList) {
$BinaryName = "articulate-parser-$platform-$arch" $BinaryName = "articulate-parser-$platform-$arch"
@@ -163,12 +163,12 @@ try {
$SelectedTargets = @() $SelectedTargets = @()
$PlatformList = $Platforms.Split(',') | ForEach-Object { $_.Trim() } $PlatformList = $Platforms.Split(',') | ForEach-Object { $_.Trim() }
$ArchList = $Architectures.Split(',') | ForEach-Object { $_.Trim() } $ArchList = $Architectures.Split(',') | ForEach-Object { $_.Trim() }
foreach ($target in $GoTargets) { foreach ($target in $GoTargets) {
$parts = $target.Split('/') $parts = $target.Split('/')
$platform = $parts[0] $platform = $parts[0]
$arch = $parts[1] $arch = $parts[1]
if ($PlatformList -contains $platform -and $ArchList -contains $arch) { if ($PlatformList -contains $platform -and $ArchList -contains $arch) {
$SelectedTargets += @{ $SelectedTargets += @{
Platform = $platform Platform = $platform
@@ -177,14 +177,14 @@ try {
} }
} }
} }
# Display filtered targets # Display filtered targets
foreach ($target in $SelectedTargets) { foreach ($target in $SelectedTargets) {
$BinaryName = "articulate-parser-$($target.Platform)-$($target.Arch)" $BinaryName = "articulate-parser-$($target.Platform)-$($target.Arch)"
if ($target.Platform -eq 'windows') { $BinaryName += '.exe' } if ($target.Platform -eq 'windows') { $BinaryName += '.exe' }
Write-Host " $($target.Original) -> $BinaryName" -ForegroundColor Gray Write-Host " $($target.Original) -> $BinaryName" -ForegroundColor Gray
} }
# Show all available targets if verbose # Show all available targets if verbose
if ($VerboseOutput) { if ($VerboseOutput) {
Write-Host "`nAll Go targets available on this system:" -ForegroundColor Cyan Write-Host "`nAll Go targets available on this system:" -ForegroundColor Cyan
@@ -404,13 +404,13 @@ try {
} }
$BuildArgs += '-o' $BuildArgs += '-o'
$BuildArgs += $Target.Path $BuildArgs += $Target.Path
# If using custom entry point that's not main.go # If using custom entry point that's not main.go
# we need to use the file explicitly to avoid duplicate declarations # we need to use the file explicitly to avoid duplicate declarations
$EntryPointPath = Join-Path $ProjectRoot $EntryPoint $EntryPointPath = Join-Path $ProjectRoot $EntryPoint
$EntryPointFile = Split-Path $EntryPointPath -Leaf $EntryPointFile = Split-Path $EntryPointPath -Leaf
$IsCustomEntryPoint = ($EntryPointFile -ne 'main.go') $IsCustomEntryPoint = ($EntryPointFile -ne 'main.go')
if ($IsCustomEntryPoint) { if ($IsCustomEntryPoint) {
# When using custom entry point, compile only that file # When using custom entry point, compile only that file
$BuildArgs += $EntryPointPath $BuildArgs += $EntryPointPath
@@ -419,7 +419,7 @@ try {
$PackagePath = Split-Path $EntryPointPath -Parent $PackagePath = Split-Path $EntryPointPath -Parent
$BuildArgs += $PackagePath $BuildArgs += $PackagePath
} }
# For verbose output, show the command that will be executed # For verbose output, show the command that will be executed
if ($VerboseOutput) { if ($VerboseOutput) {
Write-Host "Command: go $($BuildArgs -join ' ')" -ForegroundColor DarkCyan Write-Host "Command: go $($BuildArgs -join ' ')" -ForegroundColor DarkCyan
+175 -175
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)
===================================== =====================================
@@ -73,7 +73,7 @@ EXAMPLES:
DEFAULT TARGETS: DEFAULT TARGETS:
Operating Systems: darwin, freebsd, linux, windows Operating Systems: darwin, freebsd, linux, windows
Architectures: amd64, arm64 Architectures: amd64, arm64
This creates 8 binaries total (4 OS × 2 ARCH) This creates 8 binaries total (4 OS × 2 ARCH)
GO BUILD FLAGS: GO BUILD FLAGS:
@@ -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