npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@creaditor/cdtr-course-nav

v0.5.0

Published

Embeddable <cdtr-course-nav> and <cdtr-lesson-footer> web components. Draws the curriculum a course creator built, steps a learner through it, and reports which lesson was picked and which was finished. No networking, no storage, no routing: the host owns

Readme

@creaditor/cdtr-course-nav

Course navigation for a learner, in two shapes:

| | | |---|---| | <cdtr-course-nav> | The curriculum down the side — units, lessons, progress. | | <cdtr-lesson-footer> | The bar under the lesson — previous, next, "I finished this". |

Both take the { id, title, sections } that @creaditor/cdtr-course-builder emits as save-course-structure. Neither renders the lesson body between them: that is a page, and the host owns it.

They do not fetch, store, route, or decide anything. Selection and progress belong to the host: current-lesson-id and completed come in, a click goes out as lesson-selected, and nothing moves until the host says so.

<script type="module" src="https://unpkg.com/@creaditor/cdtr-course-nav"></script>

<cdtr-course-nav id="nav" language="he"></cdtr-course-nav>

<script type="module">
  const nav = document.getElementById('nav');

  nav.course = course;   // the save-course-structure payload

  nav.addEventListener('lesson-selected', (e) => {
    const { lessonId, sectionId, type, locked, dripDays } = e.detail;

    if (locked && !myRules.allows(lessonId)) return showUpsell();

    showLesson(lessonId);
    nav.setAttribute('current-lesson-id', lessonId);   // the host moves the highlight
  });

  // a click on a lesson the learner can't open yet; the menu itself doesn't change
  nav.addEventListener('lesson-locked', (e) => {
    const { lessonId, sectionId, reason } = e.detail;   // reason: 'enrol' | 'drip' | 'must-pass' | 'order'
    showWhyLocked(lessonId);   // e.g. <cdtr-lesson-locked lesson-id="…">
  });
</script>

<cdtr-lesson-footer>

The controls under the lesson body. It emits the same lesson-selected event the nav does, with the same detail, so one handler serves both and the page never cares which the learner used.

<cdtr-lesson-footer id="footer" language="he"></cdtr-lesson-footer>

<script type="module">
  const footer = document.getElementById('footer');
  footer.course = course;
  footer.completed = [];                              // opts into completion
  footer.setAttribute('current-lesson-id', lessonId);

  footer.addEventListener('lesson-completed', (e) => {
    myBackend.save(e.detail.lessonId);                // YOUR record
    footer.completed = myBackend.completedIds();      // hand it back
    nav.completed = myBackend.completedIds();
  });
</script>

| | | |---|---| | footer.course = … | Same payload the nav takes. | | current-lesson-id | Which lesson to steer around. Nothing draws without it. | | footer.completed = […] | Same record the nav takes. null turns completion off. | | footer.taken = […] | Same as the nav's. | | language, show-drafts | As the nav. Next and previous skip what the learner cannot see. | | footer.currentLesson (read) | The lesson it resolved, or null. |

Previous and next cross unit boundaries, because a learner working through a course does, and tag the step with the other unit's number. With nowhere to go, a step keeps its space on a wide bar so nothing shifts, and drops out when the tiles stack.

Next finishes the lesson

Pressing Next on an unfinished lesson emits lesson-completed, then lesson-selected. The last lesson has no Next, so it shows Finish course instead. Where Next can only wait (a drip date) the same tile offers Finish lesson, then shows the locked Next once it's done. A locked step shows a lock but stays open: it emits lesson-locked (like a locked row in the nav) instead of lesson-selected, so the host can show why. A Next into a paid lesson still finishes the lesson on screen first. Quiz lessons are never finished by Next: passing the quiz does that, so listen for the player's quiz-completed and add the lesson to completed.

{ lessonId: 'les_a', sectionId: 'sec_1', source: 'next' }   // or 'finish'

Completion is a request, not a record. Nothing settles until you hand completed back containing that id, so a save that failed leaves the learner able to finish again rather than a tick that lies.

In an in-order course the lesson after the one on screen is not blocked from Next, since Next finishes it; Previous and the nav still wait for the host's record. On a quiz not yet taken, Next is locked with "Take this quiz first".

<cdtr-course-intro>

The course's front page: title, what's inside, and one next step. It is always open, so it is the safe place for a learner to land. Give it what you give the nav:

intro.course = course;
intro.enrolled = student.paid;
intro.completed = finished;
intro.taken = taken;

Its button reads Start course, Continue (at the first unfinished lesson they may open) or Review course, and emits lesson-selected like the nav. For a learner who hasn't enrolled it offers Enrol, which emits enrol-requested for your sign-up or payment, and Try a free lesson when there is one.

Set signed-in (with student-name) and it says "Signed in as Noa" (just "Signed in" without a name) with a Not you? link, which emits sign-out-requested.

Narrower than 560px, it sets a smaller title and full-width buttons. It reads its own width (a size container), so give it one: a block in normal flow, not a shrink-to-fit flex item or auto grid track, where it would collapse.

Certificate

When the creator turned the certificate on, the course page promises it ("Finish every lesson to get a certificate"). Once the learner has finished every lesson, the course page leads with Get your certificate, and the lesson footer says "You finished the course!" with the same button, on the last lesson or wherever they finished. It opens a printable certificate in a new tab, which the learner prints or saves as a PDF. It shows the business logo and name (settings.brandName, settings.brandLogo), the learner's name, the course, the date and the creator's signer, in the course colour. Only you know who the learner is, so pass their name:

intro.studentName = footer.studentName = student.name;
intro.completedOn = footer.completedOn = student.finishedAt; // a Date, "2026-10-05" or a timestamp; else today

The click also emits certificate-requested (detail: { courseId }). Call preventDefault() on it to make your own certificate instead.

<cdtr-unit-intro>

One unit's page. It lists the unit's lessons, says why each locked one is locked, and offers the next lesson they may open (Enrol first, for a learner who hasn't enrolled).

unit.course = course;                  // and enrolled, enrolledAt, completed, taken, as above
unit.setAttribute('section-id', id);   // or lesson-id, to mark one lesson in its unit

Emits lesson-selected and enrol-requested, like the course intro.

To let learners open a unit's page from its title, add unit-pages to the nav and the course intro. Their unit titles then emit section-selected ({ sectionId }); answer it by showing this element with that section-id.

<cdtr-course-sign-in>

For a course that asks students to sign up: their email, then the code you email them, then (for a new student only) their name, in a pop-up over the page. It never calls a server; you do. Show it by removing hidden; Close, Escape or a click outside hide it and emit sign-in-closed.

signIn.course = course;   // title, colour, and whether it's paid
signIn.addEventListener('sign-in-requested', async (e) => {
  const { email, consent, consentText } = e.detail;   // send the code, then:
  ok ? signIn.codeSent() : signIn.failed('too_many_codes');
});
signIn.addEventListener('sign-in-code-entered', async (e) => {
  const { email, code } = e.detail;   // check it, then:
  if (!ok) signIn.failed('wrong_code');
  else if (isNew) signIn.askName();
  else signIn.hidden = true;
});
signIn.addEventListener('sign-in-name-entered', async (e) => {
  const { name } = e.detail;   // save it, then:
  signIn.hidden = true;
});

Set consent-label (e.g. "Send me news from Lior Ceramics") to add an unticked marketing-consent checkbox under the email; sign-in-requested then also carries consent, and consentText (the label) when ticked, for your consent record. "Send a new code" repeats the same detail; going back to the email step clears the tick.

failed takes invalid_email, too_many_codes, send_failed, wrong_code, expired_code or network, and shows a short reason. reset() goes back to the first step, keeping the email. Hiding the element clears it, so the next student starts from an empty email.

<cdtr-lesson-locked>

What a lesson shows when it won't open: the lesson faded out, with why and the way forward. Set course, lesson-id, and the nav's enrolled, enrolledAt, completed and taken.

  • In an in-order course, the lesson before: "Not open yet", "Finish “Sit” first." (or "Take “Quiz” first."), and a button to it (lesson-selected).
  • A quiz to pass: "Pass the quiz first", and a button to the quiz.
  • A release date: "Opens Oct 25", and no button.
  • A paid lesson: "Enrol to open this lesson", "One payment opens every lesson.", and Enrol with the course's price (enrol-requested).

With no lock, or with sign-up set, it shows "Sign up to open this lesson" instead. Its button emits enrol-requested { courseId, lessonId }; open the sign-in, then that lesson.

What <cdtr-course-nav> takes

| | | |---|---| | nav.course = … | The save-course-structure payload. Setting it redraws. | | current-lesson-id | Which lesson to mark. The component never sets this itself. | | language="he" \| "en" | Copy and text direction. Defaults to Hebrew, matching the builder. | | show-drafts | Draw lessons still marked status: 'draft'. Off by default. | | home-link | The course title becomes a button that emits course-home-requested { courseId }. | | home-current | Set while the host shows the course page: tints the title like the current lesson. | | panel | The host draws the nav as a flush side panel: the header becomes an edge-to-edge band. | | nav.course (read) | The parsed course. | | nav.completed = […] | Which lessons the learner has finished — an array or a Set of ids. null draws no progress at all. | | nav.taken = […] | Which quizzes the learner has submitted at least once, at any score. Only "Lessons in order" reads it. | | nav.progress (read) | { completed, total, percent } across every lesson drawn, or null when not tracking. | | nav.dropped (read) | { lessons, sections } — how much was too malformed to draw. | | nav.canOpen(id) | Whether this learner may open that lesson now, by the same rules the menu draws. | | nav.firstOpenLesson (read) | The id of the first lesson they may open, or null. Start the page here. |

What it emits

lesson-selected, from the nav and the footer alike. bubbles and composed, so one listener on an ancestor catches both:

{
  lessonId: 'les_a',
  sectionId: 'sec_1',
  type: 'lesson' | 'quiz' | 'video',
  locked: true,               // what was DRAWN, not a verdict
  dripDays: 3                 // passed through
}

Four kinds of lock

access: 'locked' marks a lesson for enrolled (paying) learners. Only you know who has paid, so tell both components:

nav.enrolled = true;     // paid: locked lessons open, no lock icon
footer.enrolled = true;

false blocks them — a lock, and a click on one emits lesson-locked (below) instead of lesson-selected. Leave it unset and they open like any other lesson: a lock is only ever drawn where it really locks.

Lessons in order (settings.inOrder: true on the course) is enforced once you track progress: each lesson waits until the one before it, across units and skipping hidden drafts, is in completed. A quiz counts once it's in taken, whatever the score. A free preview in a paid course skips the order for a learner who hasn't paid (enrolled = false), so it stays a preview, and a lesson already in completed stays open. Hand both components the quizzes the learner has submitted:

nav.taken = student.quizzesTaken;   // an array or a Set of lesson ids
footer.taken = student.quizzesTaken;

The row shows the lock with no line under the title (the list order already says it), and the click emits lesson-locked with reason: 'order'. mustPass (on a quiz) is enforced whether or not the course is in order: every later lesson waits until it's in completed, with "pass X first", and reason: 'must-pass'. Other lessons ignore it. Count a quiz completed only when it wasn't failed. drip is enforced once you say when this learner enrolled. Only you know that, so tell both components (a Date, ISO string or epoch ms):

nav.enrolledAt = student.enrolledAt;
footer.enrolledAt = student.enrolledAt;

A lesson with drip: { days: 3 } then stays locked until three days after that date, and says when it opens ("Opens Oct 3"). Leave it unset and drip lessons open straight away. The lock doesn't lift by itself while the page stays open; it lifts on the next load, or when you set enrolledAt again.

The menu only guards its own clicks. Which lesson your page shows is your code, so ask before showing one: start on nav.firstOpenLesson, and check nav.canOpen(id) for a lesson reached any other way (a link, a bookmark). This controls what the page shows, not what it can reach: keep a locked lesson's content on your server until the learner may open it.

Drafts are the one exception, and they are a drawing default rather than a rule: a draft is content the creator has not released, so hiding it from a learner is the safer default. show-drafts turns it off.

Progress is the host's too

Hand it a list of finished lesson ids and it draws ticks, a count per unit and a bar — the same marks the builder's sidebar already shows a creator.

nav.completed = ['les_a', 'les_b'];   // the host's record, handed in whole
nav.progress;                          // { completed: 2, total: 4, percent: 50 }

It accumulates nothing and decides nothing. Who marks a lesson finished is your rule — a button, reaching the end, a passing grade — and the component never guesses. Learner records belong to the customer, exactly as course content does.

Two behaviours worth knowing:

  • null and [] are different. null means "not tracking" and draws no progress chrome at all. An empty array means "tracking, nothing done yet" and draws 0/5 with an empty bar.
  • Counts are against what the learner SEES. With drafts hidden, finishing the one visible lesson of two reads 100%, not 50% of a total including work they will never be shown.

Helpers

parseCourse, flattenLessons, nextLesson, previousLesson, toCompletedSet, countProgress and courseProgress are exported for hosts doing their own routing, or building controls these elements do not draw. They are pure, and their tests run with no DOM in scope.

import { parseCourse, nextLesson } from '@creaditor/cdtr-course-nav';

const course = parseCourse(payload);
const after = nextLesson(course, 'les_b');   // crosses unit boundaries

It expects to be handed broken data

By the time a course reaches a learner it has been through the customer's database and a network hop. Nothing here throws; anything undrawable is counted in dropped.

  • A lesson with no id is dropped — a click could not report it.
  • A lesson with a blank title is kept, and the element supplies a fallback.
  • Lesson ids are deduplicated across the whole course, not per unit, because an id appearing twice would make a click unattributable.
  • A unit with no id, or left with no drawable lesson, is dropped rather than drawn as a bare heading.
  • An unrecognised type falls back to lesson rather than dropping the lesson. A kind this package has not heard of yet should still be reachable.
  • An unrecognised access or status is treated as the permissive case: hiding a learner's content because of a string nobody recognises is the worse failure.

Free and paid courses

A course whose settings.paid is not true is free: every lesson opens, whatever its own access says, so nothing shows a lock. On a paid course each lesson's access decides, and enrolled unlocks the locked ones. Course data with no settings at all keeps each lesson's own access. A paid course with a settings.price and an ISO settings.currency shows the price on every Enrol button ("Enrol · ₪1,000").

Colours

<cdtr-course-nav> looks like the builder's own curriculum sidebar, on a light page. Its colour is the course's theme (settings.theme in the course data, which the creator picks in the builder), and that always wins. A course with no theme falls back to --cdtr-accent, then violet.

| Custom property | Default | What it changes | | --- | --- | --- | | --cdtr-text | #1c2127 | Text colour | | --cdtr-font | the system UI font | Font | | --cdtr-heading-font | "Frank Ruhl Libre", Georgia, serif | Course title | | --cdtr-done | #2f9e6f | Completion ticks and unit bars |

The footer and the intro pages take the course's theme for their buttons and progress, with white or near-black text on it, whichever reads better. Every other colour derives from the host's own text colour (currentColor), so they sit on a light or a dark page without setup. For a course with no theme, set a pair on an ancestor; it themes the quiz player too:

.my-course {
  --cdtr-accent: #0f766e;        /* current lesson, progress, Finish */
  --cdtr-accent-text: #fff;      /* text on a filled button: set BOTH */
}

| Custom property | Default | What it colours | | --- | --- | --- | | --cdtr-accent | currentColor (a 10% tint for fills) | Current lesson, progress bars, Finish button, focus rings | | --cdtr-accent-text | inherited | Text on the Finish button | | --cdtr-done | a green mixed toward currentColor | Completion ticks, "Completed" | | --cdtr-nav-accent | — | Overrides --cdtr-accent for these two components only |

What this is not

No scores, no search. No routing and no URLs — the host owns those. No lesson body: what sits between the nav and the footer is a page, rendered by the host. Progress is drawn, never kept or decided.

Completion is one-way here. There is no "un-finish" control, because undoing a completion is a rule about a learner's record rather than a thing the bar under a lesson should decide. Remove the id from what you hand back and both components follow.

It never stores or fetches a course. DEC-host-owns-course-data is locked: the customer's backend owns course content, and every component here takes data in as a property and reports what happened as an event.

Development

npm install
npm test          # 132 tests, node + happy-dom in one run
npm run dev       # preview.html on :5183
npm run build

preview.html wires both elements to <cdtr-quiz-player> so a whole lesson works: pick a lesson from the curriculum, step through with the footer, and answer a quiz. It carries a well-formed course, a deliberately half-broken one, a draft toggle, a progress toggle, and a live event log. Finish a lesson with the footer's button or by answering its quiz correctly — either way the page holds the record and both components only draw it.