Edit, preview, and sync Skilljar course content locally.
The local-first Skilljar workflow tool for developers, course authors, and content pros.
Syncjar is a command-line tool that lets you pull, edit, preview, and sync course content from Skilljar β all from your local development environment.
It's your local Skilljar workspace: Write content, test changes, see diffs, and push updates upstream.
- π Two-way sync between Skilljar and local files
- βοΈ Edit each Skilljar content-item as a standalone HTML file
- π Visual diffs before syncing changes
- π§ͺ Local preview mode for testing courses offline
- πΎ Uses the Skilljar API with simple setup
.
βββ local-skilljar/
β βββ <course-slug>/
β βββ details.json
β βββ lessons-meta.json
β βββ lessons/
β βββ <lesson-slug>/
β βββ content-<content_item_id>.html
β
βββ local-skilljar-paths/
β βββ <path-title>/
β βββ details.json # The learning path itself
β βββ path-items.json # Its member courses
β βββ published.json # Per-domain slugs
β
βββ public/
β βββ courses/ # Local preview output
β βββ data/
β βββ courses.json # Course structure for preview UI
β
βββ scripts/
β βββ sync-skilljar-to-local.mjs # Pull from Skilljar
β βββ sync-local-to-skilljar.mjs # Push to Skilljar (with diffing)
β βββ pull-slugs.mjs # Pull per-domain URL slugs
β βββ pull-paths.mjs # Pull learning paths and their members
β βββ course-dirs.mjs # Map course ids to course directories
β βββ generate-courses-json.mjs # Create preview course index
β βββ export-courses-to-md.mjs # Export course content as Markdown
β βββ export-users-to-csv.mjs # Export Skilljar users to CSV
β βββ metrics-to-csv.mjs # Export course metrics to CSV
β βββ check-links.mjs # Check for broken links in content
β βββ sync-users.mjs # Sync user data from Skilljar
β
βββ .env # API key
βββ README.mdClone the repo and install dependencies:
git clone https://github.com/<your-org>/syncjar.git
cd syncjar
npm install- Add your Skilljar API key to a .env file:
SKILLJAR_API_KEY=sk-live-abc123- Pull your Skilljar content and generate the local preview index:
npm run build:preview| Command | Description |
|---|---|
npm run pull |
Pull all courses from Skilljar to local files |
npm run pull -- --course <slug> |
Pull a single course (partial match on directory name or title) |
npm run pull:slugs |
Pull each course's per-domain URL slugs |
npm run pull:paths |
Pull learning paths, their member courses, and their per-domain slugs |
npm run pull:paths -- --path <query> |
Pull a single path (partial match on directory name or title) |
npm run push |
Push local edits back to Skilljar (with diffs + prompts) |
npm run generate:courses |
Regenerate the local preview index |
npm run build:preview |
Pull + generate (full refresh) |
npm run serve |
Start the local preview server at http://localhost:3000 |
npm run export:plaintext |
Export course content as Markdown |
npm run export:users |
Export Skilljar users to CSV |
npm run export:metrics |
Export course metrics to CSV |
npm run check:links |
Check for broken links in course content |
npm run sync:users |
Sync user data from Skilljar |
npm test |
Run the unit tests |
A course's directory name comes from its course id, not its title.
npm run pull reads the id in every details.json under the content path and
builds a map of course id to directory name. A course that already has a
directory keeps that directory's name exactly, even after someone rewords or
re-cases its title in Skilljar. Only a course id that is new to the tree gets a
name derived from its title.
This is what stops a renamed course from gaining a second directory. It also keeps the tree safe on macOS, where two names that differ only in case cannot coexist.
To rename a course directory, rename it yourself. The next pull follows the new name, because it matches on the id.
Skilljar models a learning path much like a course, so npm run pull:paths
mirrors the same three objects per path into local-skilljar-paths/<path>/:
| File | Source | Contents |
|---|---|---|
details.json |
GET /paths |
The path itself, verbatim: title, descriptions, promo image, item count |
path-items.json |
GET /paths/{id}/path-items |
Its member courses, verbatim |
published.json |
GET /domains/{domain}/published-paths |
Per-domain slug, publish id, and hidden flag |
Directory naming follows the same id-not-title rule as courses, for the same reason β see the section above.
Set PATH_CONTENT_PATH in .env to write somewhere else (for example, into a
content repo alongside COURSE_CONTENT_PATH):
PATH_CONTENT_PATH=../courses/pathsSlugs are per-domain, so published.json needs to know which domains to ask
about β the same SKILLJAR_DOMAINS (or --domain) that pull:slugs uses. A
run with neither configured still writes details.json and path-items.json,
and says it is skipping published.json.
npm run pull:paths # everything
npm run pull:paths -- --path "onboarding" # one path
npm run pull:paths -- --dry-run # print, write nothing
npm run pull:paths -- --check # CI: non-zero exit if any file is staleTwo caveats worth knowing before you build anything on the output:
path-items.jsonis membership, not a running order. The API returns no order field, and the order it does return is not display order. The file preserves API order and claims nothing more.- A path id belongs to one domain on some instances and several on others.
published.jsonis keyed by domain either way, so it represents both without assuming. If your instance has separate path objects per domain, note that two different path ids can share a slug, and only the slug is common to both.
Paths are read-only here: npm run push syncs course and lesson content, and
does not write path titles or descriptions back upstream.
After editing any content file in local-skilljar/<course>/lessons/<lesson>/content-<content_item_id>.html, run:
npm run pushThis will show diffs and prompt before updating content upstream.
npm run build:previewRuns:
npm run pull
npm run generate:courses
After these scripts have run, you can edit the .html files in local-skilljar/<course>/lessons/<lesson>/.
After you make changes, you need to rebuild the local preview index:
npm run generate:coursesThen you can preview the content:
npm run serveThis starts a local server at http://localhost:3000. Each lesson is rendered inside an iframe as a full HTML document, so Skilljar theme styles and scripts are applied accurately.
Add your Skilljar theme CSS and JS URLs to preview.config.json at the project root:
{
"theme": {
"css": [
"https://your-skilljar-domain.com/path/to/theme.css"
],
"js": [
"https://your-skilljar-domain.com/path/to/theme.js"
]
}
}The preview server injects these into each lesson iframe at render time. Leave the arrays empty to preview unstyled content. You may want to add preview.config.json to .gitignore if it contains internal URLs.
npm run pushWith options:
# Dry run with diffs
npm run push -- --dry-run
# Push a specific lesson
npm run push -- --course This-Is-My-Course-Title --lesson 03-wrap-up
# Show diffs only (no syncing)
npm run push -- --diff-only
# Push content-item HTML changes without prompting
npm run push -- --force
# Push course/lesson title changes without prompting (separate from --force)
npm run push -- --force-titles
# Push without showing diffs
npm run push -- --no-diff
# Change how many read requests the scan runs at once (default 6)
npm run push -- --concurrency 3
# Force a diff layout (default: auto)
npm run push -- --diff-style stacked
npm run push -- --diff-style side-by-sideContent diffs are shown in two columns, Skilljar on the left and your local copy on the right, with the changed words highlighted in place:
Skilljar (upstream) β local
ββββββββββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββ
β― 6 unchanged line(s) β―
</li> β </li>
- <li>Automate image updates in CI/CD β + <li>Automate image updates everywhere CI/CD
</li> β </li>
β― 1 unchanged line(s) β―
Both sides are reflowed onto matching lines before being compared, using the
same whitespace rules the sync itself uses β Skilljar and your local copy
indent and wrap the same markup differently, and without that step nearly
every line reads as changed. Inside <pre>, whitespace is left exactly as it
is, because there it is content.
Those display lines are produced by the reflow, so they are not file line numbers and none are shown.
--diff-style stacked gives a one-column -/+ view instead, which is what
auto falls back to on a terminal narrower than 100 columns.
push works in two phases. The scan compares every local course against
Skilljar and builds a list of changes; it writes nothing, so it runs in
parallel. The apply then walks that list, shows each diff and prompts.
So you see the whole change list before the first prompt, rather than having prompts surface one at a time over the length of the run. Scanning the full catalogue β 66 courses, 649 lessons, 822 content items β takes about 80 seconds.
If Skilljar starts throttling (the client prints a retry notice and backs
off), lower --concurrency. Narrowing the scan with --course or --lesson
is faster still, since --lesson cuts the content reads to a single request.
Skilljar holds one live state and knows nothing about git branches. If your course content repo is on a feature branch that's behind dev/main, pushing from it silently overwrites whatever landed there in the meantime β and git can't catch that, because the danger is entirely in which branch happens to be checked out.
So npm run push refuses to run unless your course content repo (COURSE_CONTENT_PATH) is on dev or main. A detached HEAD, or a content directory that isn't a git repo at all, is also refused: nothing establishes that the content is current.
--dry-run and --diff-only never write to Skilljar, so they work from any branch.
# Push from another branch, naming it explicitly
npm run push -- --allow-branch feat/new-lessonThe guard checks which branch you're on, not whether it's up to date β pull before you push.
This repo does not track course content directly. To use it:
-
Clone your private course content repo:
git clone git@github.com:<YOUR-ORG>/<YOUR-COURSE-CONTENT-REPO>.git ~/courses
-
Add to the
.envfile in this repo:COURSE_CONTENT_PATH=../courses -
Then run:
npm run build:preview
This keeps your course content private and portable across environments.
