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

@charleslemaux/cms

v1.0.0-rc.1

Published

Client and React components for the ICRC - CMS: blog articles and a double opt-in newsletter served by Odoo.

Readme

@charleslemaux/cms

Client and React components for the ICRC - CMS, the headless blog and double opt-in newsletter that Odoo serves to the company's websites.

  • The core is a small client with no runtime dependency (it uses the native fetch). It ships TypeScript types and ESM and CommonJS builds.
  • @charleslemaux/cms/react holds unstyled React components:
    • the newsletter form, with Cloudflare Turnstile;
    • the newsletter page, to confirm or unsubscribe;
    • the article body;
    • data hooks.

Install

npm install @charleslemaux/cms        # release candidates: npm install @charleslemaux/cms@next

It needs Node 18 or later, or a browser with fetch. The React part needs React 18 or later.

Reading articles

import { createCmsClient } from '@charleslemaux/cms';

// Create it once, at module level, and reuse it.
export const cms = createCmsClient({
  baseUrl: 'https://b.projekts.com', // the Odoo that serves the CMS
  zone: 'projekts',                  // your website's zone code in Odoo
});

const zone = await cms.getZone({ lang: 'fr' });
const page = await cms.listArticles({ lang: 'fr', category: 'recruitment', page: 1, limit: 12 });
const article = await cms.getArticle('150-hires-one-year', { lang: 'fr' }); // null when not live

| Method | Answer | |---|---| | getZone({ lang?, signal? }) | Zone: name, site URL, languages, the newsletter settings, the categories with their article counts | | listArticles({ lang?, category?, tag?, page?, limit?, signal? }) | Page<ArticleSummary>: newest first; limit 1-50, default 12 | | getArticle(slug, { lang?, signal? }) | Article (a summary plus bodyHtml, alternates and related), or null when it is not live on the zone |

Options of createCmsClient:

  • fetch: your own implementation. The default is the global fetch.
  • fetchOptions: added to every read, e.g. { next: { revalidate: 60 } } in Next.js or { cache: 'no-store' }.

Languages. lang accepts fr, fr-FR or fr_FR. An unknown or missing lang gets the zone's default language. When an article has no version in that language, the source version comes back with fallback: true, and lang is set to sourceLang.

Images. cover.src is the largest version, at most 1920 pixels wide. cover.sources lists every width, smallest first, ready for srcset.

Body. Odoo cleans bodyHtml with a strict allowlist: no scripts, no styles, no classes. Style it with your own CSS, for example Tailwind Typography.

The newsletter

  1. Your form calls subscribe(). Odoo answers 202 pending and emails a confirmation link, valid for 24 hours.
  2. The link opens your newsletter page with ?confirm=<token>. The visitor clicks, and the page calls confirm().
  3. Every newsletter's unsubscribe link opens the same page with ?unsubscribe=<token>, and the page calls unsubscribe().
await cms.subscribe({ email, lang: 'fr', consent: true, captchaToken });
readNewsletterAction(window.location.href); // { action: 'confirm' | 'unsubscribe', token } or null
await cms.confirm(token);                   // { status: 'confirmed', zone, lang }
await cms.unsubscribe(token);               // { status: 'unsubscribed', zone }
  • Call these three from the visitor's browser, never from your server. Odoo checks the page's Origin against the zone's allowed origins, and limits signups per visitor IP.
  • Referrer and analytics. The package's newsletter calls (subscribe, confirm, unsubscribe) send no referrer (Referrer-Policy: no-referrer). Consider also adding <meta name="referrer" content="no-referrer"> to the newsletter page itself, and keep its query string (the confirm/unsubscribe tokens) out of your analytics (page-view tracking, session replay, etc.).
  • Set up the zone in Odoo (CMS → Configuration → Zones):
    • Allowed origins must list every origin of your site: https://www.example.com, https://example.com, preview hosts, and http://localhost:5173 on development zones;
    • Newsletter page must be the page where you render <NewsletterPage>;
    • Newsletter must be on.

Errors

Every failure throws a CmsError with status, code and message. A 429 also carries retryAfter, in seconds. isCmsError(value) also recognises an error thrown by another copy of this package.

| Status | code | |---|---| | 0 | network_error: the CMS could not be reached | | 400 | invalid_request, invalid_email, consent_required | | 403 | origin_not_allowed, captcha_failed | | 404 | not_found, invalid_token (an unknown or expired link) | | 413 / 415 | payload_too_large / unsupported_media_type | | 429 | rate_limited, with retryAfter | | 503 | captcha_unavailable | | other | http_error; invalid_response for a success that is not JSON |

getArticle() answers null instead of throwing on a 404.

In a browser, an unknown zone code or an origin missing from the zone's Allowed origins comes back with no CORS headers, so the browser blocks the response before the client ever sees the real status: it surfaces as network_error ("Could not reach …") instead of not_found or origin_not_allowed. When a call only fails from the browser (and works from curl or the smoke script), check the zone code and the allowed origins first.

React

import { ArticleBody, NewsletterForm, NewsletterPage, useArticle, useArticles, useZone } from '@charleslemaux/cms/react';

The components are unstyled. They render plain HTML with stable class names (listed below) and data-* attributes, and you style them with your own CSS.

Their labels exist in English, French, German and Spanish, picked by lang. labels overrides any of them. newsletterFormLabels(lang, overrides) and newsletterPageLabels(lang, overrides) answer that same full label set on their own, e.g. to reuse the exact strings in your own text elsewhere on the page:

import { newsletterFormLabels } from '@charleslemaux/cms/react';

const labels = newsletterFormLabels('fr'); // { email, submit, sent, invalidEmail, … }

<NewsletterForm>

<NewsletterForm
  client={cms}
  lang="fr"                     // the newsletter's language, and the captcha's and labels'
  settings={zone.newsletter}    // optional: without it, the form reads the zone itself
  labels={{ consent: <>J’accepte de recevoir la newsletter. <a href="/fr/privacy">Confidentialité</a></> }}
  onSuccess={(email) => console.log('pending confirmation', email)}
/>
  • Fields: an email field, a consent checkbox and a hidden honeypot. When the zone has a captcha, the Turnstile widget is added, and the Cloudflare script loads once per page.
  • When the zone's newsletter is off, the form renders nothing.
  • State attributes:
    • data-status on the <form>: idle, sending, sent or error;
    • data-captcha: none, pending or ready.
  • Class names:
    • cms-newsletter-form, __field, __label, __email;
    • __consent, __checkbox, __consent-label;
    • __captcha, __submit, __message.

<NewsletterPage>

Render it on the page the zone's Newsletter page points to.

<NewsletterPage client={cms} lang="fr" onDone={(result) => console.log(result.status)} />
  • Where the token comes from: ?confirm= or ?unsubscribe= in the address bar, or search (a URL, a query string, URLSearchParams, or Next.js searchParams).
  • One click. It shows one button and calls the API only when that button is clicked, so mailbox link scanners never confirm or unsubscribe anybody.
  • Once done, the token is removed from the address bar. cleanUrl={false} keeps it.
  • State attributes:
    • data-action: confirm, unsubscribe, none or unknown;
    • data-status: ready, working, done, invalid or failed.
  • Class names: cms-newsletter-page, __intro, __button, __message.

<ArticleBody>

import DOMPurify from 'dompurify';

<ArticleBody className="prose" html={article.bodyHtml} sanitize={(html) => DOMPurify.sanitize(html)} />

sanitize is optional defence in depth: Odoo already cleans the body.

Server rendering. dompurify has no sanitize under Node (isSupported is false there), so sanitize={(html) => DOMPurify.sanitize(html)} throws during a Next.js or other SSR prerender, and a browser-only sanitizer cannot clean markup React already kept from the server render anyway. On the server, either rely on Odoo's own cleaning (drop sanitize there) or use isomorphic-dompurify. In Next.js, render bodyHtml in a server component, as the recipe below already does.

Hooks, for client-side apps

const { data: zone } = useZone(cms, { lang });
const { data: page, loading, error } = useArticles(cms, { lang, category, page: 1, limit: 12 });
const { data: article } = useArticle(cms, slug, { lang }); // data is null when the article is not live
  • Each hook answers { data, error, loading }.
  • A request is cancelled when its parameters change or the component unmounts.
  • The hooks refetch when the client's baseUrl or zone changes, not when a new client object is created.

Recipes

Vite and React Router (a single-page app)

// src/cms.ts
export const cms = createCmsClient({ baseUrl: import.meta.env.VITE_CMS_URL, zone: 'projekts' });

// routes
<Route path="/impact" element={<ArticleList />} />          // useArticles(cms, { lang })
<Route path="/impact/:slug" element={<ArticleDetail />} />  // useArticle(cms, useParams().slug, { lang })
<Route path="/newsletter" element={<NewsletterPage client={cms} lang={lang} />} />

A single-page app only shows index.html to link-preview crawlers (LinkedIn, Slack). Pre-render the article routes when their previews matter.

Next.js (App Router)

Server components fetch with the core client, and Next.js caches the answers for 60 seconds:

// lib/cms.ts
import { createCmsClient } from '@charleslemaux/cms';
export const cms = createCmsClient({
  baseUrl: process.env.CMS_URL!,
  zone: 'donuts',
  fetchOptions: { next: { revalidate: 60 } },
});

// app/[locale]/blog/[slug]/page.tsx
export default async function Page({ params }: { params: Promise<{ locale: string; slug: string }> }) {
  const { locale, slug } = await params;
  const article = await cms.getArticle(slug, { lang: locale });
  if (!article) notFound();
  return <div className="prose" dangerouslySetInnerHTML={{ __html: article.bodyHtml }} />;
}

export async function generateMetadata({ params }: { params: Promise<{ locale: string; slug: string }> }) {
  const { locale, slug } = await params;
  const article = await cms.getArticle(slug, { lang: locale });
  if (!article) return {};
  return {
    title: article.title,
    alternates: {
      canonical: article.canonicalUrl,
      languages: Object.fromEntries(article.alternates.map((alternate) => [alternate.lang, alternate.url])),
    },
  };
}

The components are client components. The react entry starts with 'use client', and a client object cannot pass from a server component to a client one. So create the client in a client file:

// components/newsletter.tsx
'use client';
import { createCmsClient } from '@charleslemaux/cms';
import { NewsletterForm, NewsletterPage } from '@charleslemaux/cms/react';

const cms = createCmsClient({ baseUrl: process.env.NEXT_PUBLIC_CMS_URL!, zone: 'donuts' });

export function Signup({ lang }: { lang: string }) {
  return <NewsletterForm client={cms} lang={lang} />;
}

export function NewsletterLanding({ lang }: { lang: string }) {
  return <NewsletterPage client={cms} lang={lang} />;
}

Static export (output: 'export'):

  • articles are read at build time, so a new article appears with the next build;
  • next: { revalidate } does not apply to a static export: use a client without fetchOptions;
  • generateStaticParams must page through listArticles (limit is 1-50) to collect every slug, for every locale:
async function allSlugs(locale: string) {
  const slugs: string[] = [];
  for (let page = 1; ; page += 1) {
    const { items, total } = await cms.listArticles({ lang: locale, page, limit: 50 });
    slugs.push(...items.map((item) => item.slug));
    if (slugs.length >= total) return slugs;
  }
}

export async function generateStaticParams() {
  const locales = ['en', 'fr'];
  const bySlug = await Promise.all(locales.map(async (locale) => (
    (await allSlugs(locale)).map((slug) => ({ locale, slug }))
  )));
  return bySlug.flat();
}
  • leave search out of <NewsletterPage>: it reads the address bar in the browser.

Content Security Policy

When your site sends a CSP, allow:

  • script-src https://challenges.cloudflare.com and frame-src https://challenges.cloudflare.com (Turnstile);
  • the CMS address in connect-src and img-src, e.g. https://b.projekts.com.

If your style-src has no 'unsafe-inline', it blocks the honeypot field's inline style (used to hide it from people while bots still fill it in). Hide .cms-newsletter-form__honeypot with your own CSS instead: the wrapper carries that class for exactly this.

Developing this package

Development needs Node 20 or later.

npm install
npm test                 # Vitest: the client, the hooks and the components
npm run typecheck        # also checks the types against the API fixtures
npm run check:package    # build, 'use client' check, publint and Are The Types Wrong
npm run smoke -- http://localhost:8070 projekts   # a live Odoo against the fixtures (read-only)
npm run playground       # the example site on http://localhost:5173
npm run e2e              # signup, email and confirmation in headless Edge (dev stack + Mailpit)

The fixtures. The files in test/fixtures/api are the API's canonical answers, and the icrc_cms addon's tests hold the live API to the same files. Copy them after an API change: npm run sync-fixtures -- <path of the icrc_cms addon>.

A local build in a website, before publishing:

  1. Run npm pack here — it builds the package first (prepack), so it never packs a stale dist/.
  2. In the website, run npm install ../icrc-cms-js/charleslemaux-cms-1.0.0-rc.1.tgz.

Releasing

  1. npm version 1.0.0-rc.2 (or 1.0.0). It commits and tags.
  2. npm login (once). Then run npm publish --tag next for a release candidate, or npm publish for a final version. prepublishOnly runs the type check, the tests and the package checks first.
  3. git push --follow-tags.

License

MIT