@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.
Maintainers
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/reactholds 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@nextIt 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 globalfetch.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
- Your form calls
subscribe(). Odoo answers202 pendingand emails a confirmation link, valid for 24 hours. - The link opens your newsletter page with
?confirm=<token>. The visitor clicks, and the page callsconfirm(). - Every newsletter's unsubscribe link opens the same page with
?unsubscribe=<token>, and the page callsunsubscribe().
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
Originagainst 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 (theconfirm/unsubscribetokens) 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, andhttp://localhost:5173on development zones; - Newsletter page must be the page where you render
<NewsletterPage>; - Newsletter must be on.
- Allowed origins must list every origin of your site:
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-statuson the<form>:idle,sending,sentorerror;data-captcha:none,pendingorready.
- 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, orsearch(a URL, a query string,URLSearchParams, or Next.jssearchParams). - 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,noneorunknown;data-status:ready,working,done,invalidorfailed.
- 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
baseUrlorzonechanges, 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 withoutfetchOptions;generateStaticParamsmust page throughlistArticles(limitis 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
searchout 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.comandframe-src https://challenges.cloudflare.com(Turnstile);- the CMS address in
connect-srcandimg-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:
- Run
npm packhere — it builds the package first (prepack), so it never packs a staledist/. - In the website, run
npm install ../icrc-cms-js/charleslemaux-cms-1.0.0-rc.1.tgz.
Releasing
npm version 1.0.0-rc.2(or1.0.0). It commits and tags.npm login(once). Then runnpm publish --tag nextfor a release candidate, ornpm publishfor a final version.prepublishOnlyruns the type check, the tests and the package checks first.git push --follow-tags.
License
MIT
