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

@peerfold/react-blocks

v1.2.0

Published

Server-safe React renderer for the Peerfold lesson block contract: readable blocks as components, gate markers as the register gate, unknown types as the player card. Renders data it is handed; it never fetches.

Readme

@peerfold/react-blocks

A React renderer for Peerfold lesson block JSON. Hand it the blocks array a Peerfold API read gave you and it returns markup.

It does not fetch, and it touches no window or document anywhere. Nothing the block dispatcher reaches holds state or runs an effect either — so a server component, a HubSpot React module's server render, or a static build can draw a whole readable lesson with zero client JavaScript. (The member-plane components below — the quiz, the exam, the live panel — do hold state, and they are reached by name or through overrides, never by the dispatcher.) Fetching belongs to whoever knows the credentials; this package only draws.

npm install @peerfold/react-blocks

react is a peer dependency (^18 || ^19).

The contract it renders

Peerfold's block JSON is a public, semver'd contract: additive within a major, with an unknown-block fallback required of every renderer. Four renderers implement the same taxonomy — this package, the framework-free browser renderer in @peerfold/surfaces, the WordPress plugin's PHP Peerfold_Render, and the portal itself. A lesson drawn by any of them makes the same decisions about the same payload.

Seventeen types are drawn as markup:

heading · richText · callout · file · image · video · embed · audio · button · divider · accordion · columns · timer · process · flashcards · sorting · continue

The last five are activities, and their renders here are static: the timer prints its duration, the process prints every step, the deck prints both faces of every card, the sort prints its items and its categories, and the Continue gate prints its marker already open so nothing below it is hidden. That is the block registry's own render for each of them and what the hosted portal serves before it hydrates — the whole authored content, never half of it. A stateful version cannot live in this list, because a server component rendering a useState would throw; pass your own through overrides to get one, the way the quiz is passed. @peerfold/surfaces' render-lesson-native.js draws all five live in these same class names, so an override styles itself.

Two are readable but live, so they render the player card — a sentence and a link to the same lesson in the hosted course player:

resources · form

Neither is a judgment about interactivity; each is a fact about what did not travel with the block. resources carries record ids whose contents sit behind a learner-plane read this package has no credential for, and form carries a pointer at a form whose target has to be resolved before there is anything to draw.

Three are replaced by the register gate on a public page, because each produces a result that has to be stored against a person and there is no person yet:

quiz · discussion · interactive

Four lesson KINDS are the gate whole, whatever blocks came with them:

exam · discussion · live · scorm

An exam is the gate on a PUBLIC page for the same reason a quiz is — an exam result has to be stored against a person. On the member plane there is one, and <ExamBlock> draws it; see below.

Everything else renders the player card. That is the contract, not an error path — the registry grows in minors and this package ships on its own release train, so meeting a type it has never heard of is expected. It is never an error, never silence, and never a dump of the JSON.

The gate rules, in the order they are applied

  1. A block that is not an object → player card.
  2. A gate marker → the register gate. A marker is { type: "gate" }, or any block carrying gated: true (on the block or in its props). It is read BEFORE the type table, so a marker on a readable type still gates, and no override can replace it.
  3. A gated type with no marker → the register gate. This one an override CAN replace: the list above is this package's conservative default, not the server's instruction, so a member-plane consumer whose payload legitimately carries a quiz supplies its own island for it.
  4. A live type, or a type not on the readable list → player card, overridable.
  5. Otherwise → its component.

<GateMarker> is given a subject and nothing else. It has no block to read, so there is no field it could accidentally print.

Usage

A server component, with islands for the interactive parts

import { LessonBlocks } from "@peerfold/react-blocks";

import { QuizIsland } from "./QuizIsland"; // "use client", or a HubSpot <Island>

export default async function LessonPage({ params }: { params: { slug: string } }) {
  // Fetch wherever fetching belongs — a server component, getServerSideProps,
  // a loader. This package is handed the result.
  const lesson = await getLesson(params.slug);

  return (
    <article>
      <h1>{lesson.title}</h1>
      <LessonBlocks
        blocks={lesson.blocks}
        options={{
          playerUrl: `https://academy.example.com/learn/${lesson.courseSlug}/${lesson.slug}`,
          registerUrl: `https://academy.example.com/signin?next=${encodeURIComponent(here)}`,
        }}
        theme={{ primary: "#0b5ed7", radius: "4px" }}
        overrides={{ quiz: QuizIsland }}
      />
    </article>
  );
}

Everything in the readable set renders as native markup — <details> for an accordion, <video>/<audio> for media, an <iframe> for a frame — so dropping overrides entirely gives you a page with no client JavaScript at all. Each override is where a client boundary goes: mark the component "use client" in Next, or wrap it in HubSpot's <Island>, and the rest of the lesson stays server-only.

The quiz, for a signed-in member

A gated type is the gate only because a public page has nobody to store a result against. On the member plane there IS somebody, so the package ships the quiz as a component and createQuizBlock binds it to the lesson and enrollment the override door does not carry:

import { LessonBlocks, createQuizBlock } from "@peerfold/react-blocks";

<LessonBlocks
  blocks={lesson.blocks}
  overrides={{
    quiz: createQuizBlock({
      lessonId: lesson.id,
      enrollmentId: enrollment.id,
      initialScore: progress.quiz_scores[lesson.id] ?? null,
      // POST /api/v1/lms/enrollments/{id}/quiz-attempts — yours to supply.
      submit: ({ lessonId, blockId, answers }) =>
        client.quizzes.submit(enrollment.id, { lesson_id: lessonId, block_id: blockId, answers }),
    }),
  }}
/>;

It draws the questions and never learns an answer: keys are stripped server-side and grading is the endpoint's, so the component holds no key, computes no score, and shows an explanation only when a graded response carried one. A quiz that arrived as a gate MARKER still draws the gate — an override cannot replace a marker, and a marker on the member plane is the server saying this member may not take it. @peerfold/react has useQuizAttempt if you want the submit written for you.

The final exam, for a signed-in member

<Lesson> makes an exam lesson the register gate, because on a public page there is nobody to record an attempt against. On the member plane there is, and this is the component that makes a certificate course finishable off the hosted portal rather than merely readable there:

import { ExamBlock } from "@peerfold/react-blocks";
import { useExam, useExamAttempt } from "@peerfold/react";

const { data: exam } = useExam(lesson.id);
const submitExam = useExamAttempt(exam?.enrollment_id);

exam ? <ExamBlock exam={exam} submit={submitExam} options={options} /> : null;

The exam is the one lesson kind whose body is not block JSON. Its questions live on the lesson's settings, so they have never travelled in CourseDetail.outline and never will — GET /api/v1/lms/lessons/{id}/exam on the learner plane is where they come from, with answers[].correct and every explanation already removed server-side. Grading is POST /api/v1/lms/enrollments/{id}/exam-attempts, against the immutable published revision.

So the component holds no key, computes no score, declares no pass, and shows an explanation only when a graded response carried one. The attempt counters it prints are the server's: the payload's until an attempt is graded, the graded response's afterwards, and cycle-scoped throughout — the lifetime counters would tell somebody renewing a certificate that their attempts are gone.

The live session, for a signed-in member

<Lesson> makes a live lesson the register gate too, and for the same reason: a seat belongs to a person. On the member plane it draws:

import { LiveSessionPanel } from "@peerfold/react-blocks";
import { useLiveSession, useLiveRegistration, useLiveIcsUrl } from "@peerfold/react";

const { data: live, reload } = useLiveSession(lesson.id);
const registration = useLiveRegistration(live?.session_id, { onChange: reload });
const ics = useLiveIcsUrl(live?.session_id);

<LiveSessionPanel
  view={live}
  onRegister={registration.register}
  onCancel={registration.cancel}
  onCheckIn={registration.checkIn}
  error={registration.error}
  icsHref={ics}
/>;

error is worth passing: useLiveRegistration swallows a refusal into its own error and resolves null, so without it the panel has only its generic line to show. With it the server's own sentence reaches the learner.

A live lesson's entire content is a date, a seat and a link, which is why this had to be a component rather than a card: telling a learner that a webinar "opens in the course player" was true and useless. The panel shows the schedule, the seats left, register and cancel, the learner's own "I attended", the join button, an add-to-calendar menu (Google, Outlook.com, Microsoft 365, and an .ics for every calendar not on that list), the recording afterwards, and a series' other dates — where booking one MOVES the seat rather than taking a second.

The join button is drawn from join_url and from nothing else. The server releases that field only inside the join window and only to a seat-holder, because a pasted meeting URL's only access control is not being handed out. A panel that computed its own condition from state plus registration_state would draw a button with nothing to put in it.

Every action resolves with the WHOLE view again and the panel repaints from that answer, never from arithmetic of its own — seats, full, checkin_open and which date of a series a seat sits on all move together with the write. A refusal leaves the panel exactly as it was with the server's own sentence added ("This session is full"), because the seat was not taken and trying again costs nothing.

Times. A server render has no idea what zone the reader is in, so the first render formats in the SESSION'S own zone — deterministic on both sides of hydration, and what a crawler and a JavaScript-less reader keep. After mount the component resolves the browser's zone and promotes it, naming the session's own underneath when the two differ. Pass any subset of labels to reword the panel; everything you leave out falls back to the portal's own English.

Omit an action and its control is not drawn. A read-only panel — an island that can see a session but has no way to write to it — is a supported state, not a degraded one.

The SCORM package, framed rather than shipped

import { ScormFrame } from "@peerfold/react-blocks";
import { useScormLaunchUrl } from "@peerfold/react";

const src = useScormLaunchUrl(course.slug, lesson.slug);

<ScormFrame src={src} title={lesson.title} options={options} />;

The run-time is not shipped as JavaScript and will not be. SCORM's discovery contract walks window.parent.API / API_1484_11, which is a cross-origin property read unless the content frame is same-origin with the page running the API, and completion is derived server-side from CMI commits precisely so a client cannot assert it. Both survive only while Peerfold's own page does the playback. So the package is OPENED, at /api/portal/embed/scorm?token=…&course=…&lesson=…, which exchanges the learner token for a partitioned cookie session and redirects the frame into the player.

ONE LAUNCH PER TOKEN. The launcher burns it, so a src is good for exactly one load — compose a fresh one per mount and never cache one. useScormLaunchUrl recomposes whenever its inputs change, which is the behavior to rely on.

THREE PREREQUISITES, and a blank frame is almost always one of them:

  1. the client's baseUrl must be the workspace's own portal host (its custom domain or {workspace}.site.…), never the admin origin;
  2. the embedding page's origin must be on the workspace's allowed-origins list (Peerfold → Settings → Developers → Allowed origins). That list doubles as the frame-ancestors allowlist for the player and the package inside it. The WordPress and HubSpot connect flows register an origin automatically; a hand-built front end has to add its own, and until it does the browser refuses the frame;
  3. the workspace needs the scorm entitlement — playback inside the hosted portal is never plan-gated, only this cross-site door is.

A refused frame is a browser-level refusal that no script on the page can detect, so <ScormFrame> draws its fallback UNDER the frame rather than instead of it: a learner looking at a blank stage still has something to press. With src null — still composing, or no token configured — the fallback is all there is. Pass fallback={null} to draw nothing, or your own node to replace the card.

The renewal banner

import { Recert } from "@peerfold/react-blocks";

<Recert
  recert={course.recert}
  renewHref={`?lesson=${course.recert?.renew_target}`}
  courseHref={`/courses/${course.slug}`}
/>;

recert is null on nearly every course — no renewal policy, no enrollment, or a credential that never expires — and <Recert> renders nothing for all of them, so a page that adds this is byte-identical for every course that does not recertify. <RecertLine> and <RecertBanner> are exported separately for a page that wants one and not the other.

Neither is a gate. Expiry is a state of the CREDENTIAL, never of the course: an expired certificate revokes no access, the progress is intact, and the banner's second sentence says so in words. The call to action is one of exactly three — the no-exam explanation, a priced button to the course page when a renewal must be bought first, or a plain link to the exam lesson — and the server has already decided which by the time the payload arrives.

Dates are formatted in UTC, the same discipline the portal keeps: a certificate that expires on the 15th expires on the 15th everywhere, and the markup is identical on both sides of hydration with no effect needed.

The outline rail and video transcripts

Two things a lesson page shows that are not blocks:

import { OutlineRail, VideoTranscript, videoTranscript } from "@peerfold/react-blocks";

<OutlineRail
  chapters={course.outline}
  currentLessonId={lesson.id}
  completed={progress.completed_lessons}
  href={(l) => `?lesson=${l.slug}`}
/>;

<VideoTranscript cues={videoTranscript(block, course.videos)} />;

OutlineRail draws the 🔒 on any lesson the payload marks locked — the hosted portal's own sequential-lock set, which its lesson route DENIES — as a word and a padlock rather than as a link. A signed-out reader sees no locks, and that needs no branch in your code: the field is learner-plane only and has no public twin, because sequencing is enforced against an enrollment's progress and a visitor has none.

videoTranscript(block, videos) is the join you would otherwise write by hand and get subtly wrong: a hosted video names its asset in videoUid (older content in videoId) while the payload keys assets by uid, and the author's showTranscript switch has to be honored in one place. It returns null unless there are cues to draw, which is worth drawing even where the video itself falls back to the player card — the words are the searchable, translatable, screen-readable half of the lesson.

One whole lesson, gate rules included

import { Lesson } from "@peerfold/react-blocks";

<Lesson lesson={{ title, kind, blocks }} options={options} />;

<Lesson> applies the whole-lesson rule: an exam, live, scorm or discussion lesson is the register gate and nothing else, whatever blocks arrived with it.

The four factories, for the types whose other half is a request

quiz, form, resources and discussion each need a call before there is anything to draw, and this package makes none. Rather than leaving you to write the component, it ships the component and takes the answer as an argument:

import {
  createDiscussionEmbedBlock,
  createFormBlock,
  createQuizBlock,
  createResourcesBlock,
} from "@peerfold/react-blocks";
import {
  useDiscussionEmbeds,
  useLessonForms,
  useLessonFormSubmit,
  useQuizAttempt,
  useResources,
} from "@peerfold/react";

const { data: forms } = useLessonForms(lessonId);
const submitForm = useLessonFormSubmit(lessonId);
const { data: resources } = useResources();
const submitQuiz = useQuizAttempt(enrollmentId);
const { threads, cohorts, reply } = useDiscussionEmbeds(lesson.blocks);

const overrides = useMemo(
  () => ({
    quiz: createQuizBlock({ lessonId, enrollmentId, submit: submitQuiz }),
    form: createFormBlock({ forms: forms ?? [], submit: submitForm }),
    resources: createResourcesBlock({ resources: resources ?? [] }),
    discussion: createDiscussionEmbedBlock({ threads, cohorts, reply, baseUrl }),
  }),
  [lessonId, enrollmentId, submitQuiz, forms, submitForm, resources, threads, cohorts, reply, baseUrl],
);

<LessonBlocks blocks={lesson.blocks} options={options} overrides={overrides} />

Memoize. Each factory returns a new component type on every call, and React treats a new type as a remount. On a quiz that loses the selected answers; on a form it loses whatever somebody had typed.

Pass nothing and nothing breaks. A page that supplies no overrides draws the player card, exactly as it did before these existed, which is why PLAYER_TYPES still names resources and form and GATED_TYPES still names discussion.

A gate marker still wins. An override replaces the player card and never a marker: a marker is the server saying it withheld the block.

The discussion block is a POINTER, a room slug or a thread slug frozen into the revision the way a link is, and discussionEmbedTargets(blocks) is how a caller learns which slugs to resolve without learning the props. display: card draws the name, the counts and a way in; display: preview adds the most recent rows, and on a thread a reply box. Its GATED_TYPES entry is not a conservative default: the community plane has no anonymous read, so a reader with no account has nothing to be shown and the register gate is the literal answer.

<DiscussionPanel> is not an override, because a discussion lesson is not a block. It sits beside the taxonomy like <ExamBlock> does, and takes the GET /api/v1/lms/lessons/{id}/discussion view plus a reply function.

Styling

Every element carries the same class names @peerfold/surfaces emits, and that package's css/peerfold.css already styles all of them. Load it and you have a finished look; write your own CSS against the same names and you have yours.

Retinting is twelve CSS custom properties — --peerfold-primary, --peerfold-surface, --peerfold-text, --peerfold-muted, --peerfold-border, --peerfold-radius, and the rest. Set them yourself, or pass a theme and let themeVars write them as inline style on the wrapper, so the palette arrives with the server-rendered HTML rather than after a request. An unset key is omitted rather than defaulted, so the stylesheet's own value (or the host page's) wins.

Safety

richText.html and embed.html are the only two fields that reach dangerouslySetInnerHTML, and both go through an allowlist sanitizer first — tags, attributes, URL schemes, forced sandbox on every iframe, forced rel="noopener noreferrer" on every new-tab link. Every URL this package emits passes a scheme check that rejects javascript:, data:, vbscript: and file:. The API sanitizes what it serves; this is the second line, and it exists because "the server promised" is not a posture to print somebody else's markup on.

Exports

| | | | --- | --- | | <LessonBlocks> | walk a block array | | <Lesson> | one lesson, whole-lesson gate rules included | | <BlockRenderer> | one block, no wrapper | | <GateMarker> <PlayerCard> | the two affordances | | <QuizBlock> createQuizBlock DEFAULT_QUIZ_LABELS | the member-plane quiz, and its copy | | <FormBlock> createFormBlock | a lesson form, with the resolved questions injected | | <ResourcesBlock> createResourcesBlock | a resource list, with the library rows injected | | <DiscussionPanel> replyDocument documentParagraphs DEFAULT_DISCUSSION_LABELS | a discussion lesson's conversation, its Tiptap helpers, and its copy | | <DiscussionEmbed> createDiscussionEmbedBlock discussionEmbedTargets DEFAULT_DISCUSSION_EMBED_LABELS | a discussion block's room or thread, the pointers to resolve, and its copy | | <ExamBlock> DEFAULT_EXAM_LABELS | the member-plane final exam, and its copy | | <LiveSessionPanel> liveCalendarLinks DEFAULT_LIVE_LABELS | the member-plane live session, its calendar menu, and its copy | | <ScormFrame> | a packaged lesson, framed at the SDK's launch URL | | <Recert> <RecertBanner> <RecertLine> DEFAULT_RECERT_LABELS | the renewal banner and the compliance line, and their copy | | <OutlineRail> DEFAULT_OUTLINE_RAIL_LABELS | the course around the lesson, sequence locks included | | <VideoTranscript> videoTranscript | a hosted video's words, and the block↔asset join | | <HeadingBlock><ColumnsBlock> | the twelve readable types, usable alone | | <TimerBlock> <ProcessBlock> <FlashcardsBlock> <SortingBlock> <ContinueBlock> DEFAULT_ACTIVITY_LABELS | the five activities, and their copy | | themeVars | a palette as inline custom properties | | SERVER_TYPES PLAYER_TYPES GATED_TYPES GATED_KINDS MAX_DEPTH | the taxonomy, as data | | gateSentence resolveOptions DEFAULT_OPTIONS | the copy and its defaults | | safeUrl parseVideoUrl embedUrl | the URL helpers | | sanitizeRichText sanitizeEmbedHtml isSafeUrl | the sanitizer |

Developing

pnpm --filter @peerfold/react-blocks typecheck
pnpm --filter @peerfold/react-blocks test:react-blocks   # builds, then runs the suite

The suite renders through react-dom/server in a runtime with no DOM, and pins the promises this package makes: every readable type draws its own markup, an unknown type takes the card, a gate marker never leaks content and no override can replace it, and quiz-shaped data never renders an answer field.