@charleslemaux/cms 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Charles Le Maux
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,347 @@
1
+ # @charleslemaux/cms
2
+
3
+ Client and React components for the **ICRC - CMS**, the headless blog and double opt-in newsletter
4
+ that Odoo serves to the company's websites.
5
+
6
+ - The core is a small client with no runtime dependency (it uses the native `fetch`). It ships
7
+ TypeScript types and ESM and CommonJS builds.
8
+ - `@charleslemaux/cms/react` holds unstyled React components:
9
+ - the newsletter form, with Cloudflare Turnstile;
10
+ - the newsletter page, to confirm or unsubscribe;
11
+ - the article body;
12
+ - data hooks.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install @charleslemaux/cms # release candidates: npm install @charleslemaux/cms@next
18
+ ```
19
+
20
+ It needs Node 18 or later, or a browser with `fetch`. The React part needs React 18 or later.
21
+
22
+ ## Reading articles
23
+
24
+ ```ts
25
+ import { createCmsClient } from '@charleslemaux/cms';
26
+
27
+ // Create it once, at module level, and reuse it.
28
+ export const cms = createCmsClient({
29
+ baseUrl: 'https://b.projekts.com', // the Odoo that serves the CMS
30
+ zone: 'projekts', // your website's zone code in Odoo
31
+ });
32
+
33
+ const zone = await cms.getZone({ lang: 'fr' });
34
+ const page = await cms.listArticles({ lang: 'fr', category: 'recruitment', page: 1, limit: 12 });
35
+ const article = await cms.getArticle('150-hires-one-year', { lang: 'fr' }); // null when not live
36
+ ```
37
+
38
+ | Method | Answer |
39
+ |---|---|
40
+ | `getZone({ lang?, signal? })` | `Zone`: name, site URL, languages, the newsletter settings, the categories with their article counts |
41
+ | `listArticles({ lang?, category?, tag?, page?, limit?, signal? })` | `Page<ArticleSummary>`: newest first; `limit` 1-50, default 12 |
42
+ | `getArticle(slug, { lang?, signal? })` | `Article` (a summary plus `bodyHtml`, `alternates` and `related`), or `null` when it is not live on the zone |
43
+
44
+ Options of `createCmsClient`:
45
+ - `fetch`: your own implementation. The default is the global `fetch`.
46
+ - `fetchOptions`: added to every read, e.g. `{ next: { revalidate: 60 } }` in Next.js or
47
+ `{ cache: 'no-store' }`.
48
+
49
+ **Languages.** `lang` accepts `fr`, `fr-FR` or `fr_FR`. An unknown or missing `lang` gets the zone's
50
+ default language. When an article has no version in that language, the source version comes back
51
+ with `fallback: true`, and `lang` is set to `sourceLang`.
52
+
53
+ **Images.** `cover.src` is the largest version, at most 1920 pixels wide. `cover.sources` lists every
54
+ width, smallest first, ready for `srcset`.
55
+
56
+ **Body.** Odoo cleans `bodyHtml` with a strict allowlist: no scripts, no styles, no classes. Style it
57
+ with your own CSS, for example Tailwind Typography.
58
+
59
+ ## The newsletter
60
+
61
+ 1. Your form calls `subscribe()`. Odoo answers `202 pending` and emails a confirmation link, valid
62
+ for 24 hours.
63
+ 2. The link opens your newsletter page with `?confirm=<token>`. The visitor clicks, and the page calls
64
+ `confirm()`.
65
+ 3. Every newsletter's unsubscribe link opens the same page with `?unsubscribe=<token>`, and the page
66
+ calls `unsubscribe()`.
67
+
68
+ ```ts
69
+ await cms.subscribe({ email, lang: 'fr', consent: true, captchaToken });
70
+ readNewsletterAction(window.location.href); // { action: 'confirm' | 'unsubscribe', token } or null
71
+ await cms.confirm(token); // { status: 'confirmed', zone, lang }
72
+ await cms.unsubscribe(token); // { status: 'unsubscribed', zone }
73
+ ```
74
+
75
+ - **Call these three from the visitor's browser, never from your server.** Odoo checks the page's
76
+ `Origin` against the zone's allowed origins, and limits signups per visitor IP.
77
+ - **Referrer and analytics.** The package's newsletter calls (`subscribe`, `confirm`, `unsubscribe`)
78
+ send no referrer (`Referrer-Policy: no-referrer`). Consider also adding
79
+ `<meta name="referrer" content="no-referrer">` to the newsletter page itself, and keep its query
80
+ string (the `confirm`/`unsubscribe` tokens) out of your analytics (page-view tracking, session
81
+ replay, etc.).
82
+ - **Set up the zone in Odoo** (CMS → Configuration → Zones):
83
+ - *Allowed origins* must list every origin of your site: `https://www.example.com`,
84
+ `https://example.com`, preview hosts, and `http://localhost:5173` on development zones;
85
+ - *Newsletter page* must be the page where you render `<NewsletterPage>`;
86
+ - *Newsletter* must be on.
87
+
88
+ ## Errors
89
+
90
+ Every failure throws a `CmsError` with `status`, `code` and `message`. A 429 also carries
91
+ `retryAfter`, in seconds. `isCmsError(value)` also recognises an error thrown by another copy of
92
+ this package.
93
+
94
+ | Status | `code` |
95
+ |---|---|
96
+ | 0 | `network_error`: the CMS could not be reached |
97
+ | 400 | `invalid_request`, `invalid_email`, `consent_required` |
98
+ | 403 | `origin_not_allowed`, `captcha_failed` |
99
+ | 404 | `not_found`, `invalid_token` (an unknown or expired link) |
100
+ | 413 / 415 | `payload_too_large` / `unsupported_media_type` |
101
+ | 429 | `rate_limited`, with `retryAfter` |
102
+ | 503 | `captcha_unavailable` |
103
+ | other | `http_error`; `invalid_response` for a success that is not JSON |
104
+
105
+ `getArticle()` answers `null` instead of throwing on a 404.
106
+
107
+ **In a browser**, an unknown zone code or an origin missing from the zone's *Allowed origins* comes
108
+ back with no CORS headers, so the browser blocks the response before the client ever sees the real
109
+ status: it surfaces as `network_error` ("Could not reach …") instead of `not_found` or
110
+ `origin_not_allowed`. When a call only fails from the browser (and works from curl or the smoke
111
+ script), check the zone code and the allowed origins first.
112
+
113
+ ## React
114
+
115
+ ```tsx
116
+ import { ArticleBody, NewsletterForm, NewsletterPage, useArticle, useArticles, useZone } from '@charleslemaux/cms/react';
117
+ ```
118
+
119
+ The components are unstyled. They render plain HTML with stable class names (listed below) and
120
+ `data-*` attributes, and you style them with your own CSS.
121
+
122
+ Their labels exist in English, French, German and Spanish, picked by `lang`. `labels` overrides any
123
+ of them. `newsletterFormLabels(lang, overrides)` and `newsletterPageLabels(lang, overrides)` answer
124
+ that same full label set on their own, e.g. to reuse the exact strings in your own text elsewhere on
125
+ the page:
126
+
127
+ ```ts
128
+ import { newsletterFormLabels } from '@charleslemaux/cms/react';
129
+
130
+ const labels = newsletterFormLabels('fr'); // { email, submit, sent, invalidEmail, … }
131
+ ```
132
+
133
+ ### `<NewsletterForm>`
134
+
135
+ ```tsx
136
+ <NewsletterForm
137
+ client={cms}
138
+ lang="fr" // the newsletter's language, and the captcha's and labels'
139
+ settings={zone.newsletter} // optional: without it, the form reads the zone itself
140
+ labels={{ consent: <>J’accepte de recevoir la newsletter. <a href="/fr/privacy">Confidentialité</a></> }}
141
+ onSuccess={(email) => console.log('pending confirmation', email)}
142
+ />
143
+ ```
144
+
145
+ - **Fields:** an email field, a consent checkbox and a hidden honeypot. When the zone has a captcha,
146
+ the Turnstile widget is added, and the Cloudflare script loads once per page.
147
+ - **When the zone's newsletter is off,** the form renders nothing.
148
+ - **State attributes:**
149
+ - `data-status` on the `<form>`: `idle`, `sending`, `sent` or `error`;
150
+ - `data-captcha`: `none`, `pending` or `ready`.
151
+ - **Class names:**
152
+ - `cms-newsletter-form`, `__field`, `__label`, `__email`;
153
+ - `__consent`, `__checkbox`, `__consent-label`;
154
+ - `__captcha`, `__submit`, `__message`.
155
+
156
+ ### `<NewsletterPage>`
157
+
158
+ Render it on the page the zone's *Newsletter page* points to.
159
+
160
+ ```tsx
161
+ <NewsletterPage client={cms} lang="fr" onDone={(result) => console.log(result.status)} />
162
+ ```
163
+
164
+ - **Where the token comes from:** `?confirm=` or `?unsubscribe=` in the address bar, or `search` (a
165
+ URL, a query string, `URLSearchParams`, or Next.js `searchParams`).
166
+ - **One click.** It shows one button and calls the API only when that button is clicked, so mailbox
167
+ link scanners never confirm or unsubscribe anybody.
168
+ - **Once done,** the token is removed from the address bar. `cleanUrl={false}` keeps it.
169
+ - **State attributes:**
170
+ - `data-action`: `confirm`, `unsubscribe`, `none` or `unknown`;
171
+ - `data-status`: `ready`, `working`, `done`, `invalid` or `failed`.
172
+ - **Class names:** `cms-newsletter-page`, `__intro`, `__button`, `__message`.
173
+
174
+ ### `<ArticleBody>`
175
+
176
+ ```tsx
177
+ import DOMPurify from 'dompurify';
178
+
179
+ <ArticleBody className="prose" html={article.bodyHtml} sanitize={(html) => DOMPurify.sanitize(html)} />
180
+ ```
181
+
182
+ `sanitize` is optional defence in depth: Odoo already cleans the body.
183
+
184
+ **Server rendering.** `dompurify` has no `sanitize` under Node (`isSupported` is false there), so
185
+ `sanitize={(html) => DOMPurify.sanitize(html)}` throws during a Next.js or other SSR prerender, and
186
+ a browser-only sanitizer cannot clean markup React already kept from the server render anyway. On
187
+ the server, either rely on Odoo's own cleaning (drop `sanitize` there) or use
188
+ `isomorphic-dompurify`. In Next.js, render `bodyHtml` in a server component, as the recipe below
189
+ already does.
190
+
191
+ ### Hooks, for client-side apps
192
+
193
+ ```tsx
194
+ const { data: zone } = useZone(cms, { lang });
195
+ const { data: page, loading, error } = useArticles(cms, { lang, category, page: 1, limit: 12 });
196
+ const { data: article } = useArticle(cms, slug, { lang }); // data is null when the article is not live
197
+ ```
198
+
199
+ - Each hook answers `{ data, error, loading }`.
200
+ - A request is cancelled when its parameters change or the component unmounts.
201
+ - The hooks refetch when the client's `baseUrl` or `zone` changes, not when a new client object is
202
+ created.
203
+
204
+ ## Recipes
205
+
206
+ ### Vite and React Router (a single-page app)
207
+
208
+ ```tsx
209
+ // src/cms.ts
210
+ export const cms = createCmsClient({ baseUrl: import.meta.env.VITE_CMS_URL, zone: 'projekts' });
211
+
212
+ // routes
213
+ <Route path="/impact" element={<ArticleList />} /> // useArticles(cms, { lang })
214
+ <Route path="/impact/:slug" element={<ArticleDetail />} /> // useArticle(cms, useParams().slug, { lang })
215
+ <Route path="/newsletter" element={<NewsletterPage client={cms} lang={lang} />} />
216
+ ```
217
+
218
+ A single-page app only shows `index.html` to link-preview crawlers (LinkedIn, Slack). Pre-render the
219
+ article routes when their previews matter.
220
+
221
+ ### Next.js (App Router)
222
+
223
+ Server components fetch with the core client, and Next.js caches the answers for 60 seconds:
224
+
225
+ ```tsx
226
+ // lib/cms.ts
227
+ import { createCmsClient } from '@charleslemaux/cms';
228
+ export const cms = createCmsClient({
229
+ baseUrl: process.env.CMS_URL!,
230
+ zone: 'donuts',
231
+ fetchOptions: { next: { revalidate: 60 } },
232
+ });
233
+
234
+ // app/[locale]/blog/[slug]/page.tsx
235
+ export default async function Page({ params }: { params: Promise<{ locale: string; slug: string }> }) {
236
+ const { locale, slug } = await params;
237
+ const article = await cms.getArticle(slug, { lang: locale });
238
+ if (!article) notFound();
239
+ return <div className="prose" dangerouslySetInnerHTML={{ __html: article.bodyHtml }} />;
240
+ }
241
+
242
+ export async function generateMetadata({ params }: { params: Promise<{ locale: string; slug: string }> }) {
243
+ const { locale, slug } = await params;
244
+ const article = await cms.getArticle(slug, { lang: locale });
245
+ if (!article) return {};
246
+ return {
247
+ title: article.title,
248
+ alternates: {
249
+ canonical: article.canonicalUrl,
250
+ languages: Object.fromEntries(article.alternates.map((alternate) => [alternate.lang, alternate.url])),
251
+ },
252
+ };
253
+ }
254
+ ```
255
+
256
+ **The components are client components.** The `react` entry starts with `'use client'`, and a
257
+ client object cannot pass from a server component to a client one. So create the client in a
258
+ client file:
259
+
260
+ ```tsx
261
+ // components/newsletter.tsx
262
+ 'use client';
263
+ import { createCmsClient } from '@charleslemaux/cms';
264
+ import { NewsletterForm, NewsletterPage } from '@charleslemaux/cms/react';
265
+
266
+ const cms = createCmsClient({ baseUrl: process.env.NEXT_PUBLIC_CMS_URL!, zone: 'donuts' });
267
+
268
+ export function Signup({ lang }: { lang: string }) {
269
+ return <NewsletterForm client={cms} lang={lang} />;
270
+ }
271
+
272
+ export function NewsletterLanding({ lang }: { lang: string }) {
273
+ return <NewsletterPage client={cms} lang={lang} />;
274
+ }
275
+ ```
276
+
277
+ **Static export** (`output: 'export'`):
278
+ - articles are read at build time, so a new article appears with the next build;
279
+ - `next: { revalidate }` does not apply to a static export: use a client without `fetchOptions`;
280
+ - `generateStaticParams` must page through `listArticles` (`limit` is 1-50) to collect every slug,
281
+ for every locale:
282
+
283
+ ```tsx
284
+ async function allSlugs(locale: string) {
285
+ const slugs: string[] = [];
286
+ for (let page = 1; ; page += 1) {
287
+ const { items, total } = await cms.listArticles({ lang: locale, page, limit: 50 });
288
+ slugs.push(...items.map((item) => item.slug));
289
+ if (slugs.length >= total) return slugs;
290
+ }
291
+ }
292
+
293
+ export async function generateStaticParams() {
294
+ const locales = ['en', 'fr'];
295
+ const bySlug = await Promise.all(locales.map(async (locale) => (
296
+ (await allSlugs(locale)).map((slug) => ({ locale, slug }))
297
+ )));
298
+ return bySlug.flat();
299
+ }
300
+ ```
301
+
302
+ - leave `search` out of `<NewsletterPage>`: it reads the address bar in the browser.
303
+
304
+ ### Content Security Policy
305
+
306
+ When your site sends a CSP, allow:
307
+ - `script-src https://challenges.cloudflare.com` and `frame-src https://challenges.cloudflare.com`
308
+ (Turnstile);
309
+ - the CMS address in `connect-src` and `img-src`, e.g. `https://b.projekts.com`.
310
+
311
+ If your `style-src` has no `'unsafe-inline'`, it blocks the honeypot field's inline style (used to
312
+ hide it from people while bots still fill it in). Hide `.cms-newsletter-form__honeypot` with your
313
+ own CSS instead: the wrapper carries that class for exactly this.
314
+
315
+ ## Developing this package
316
+
317
+ Development needs Node 20 or later.
318
+
319
+ ```bash
320
+ npm install
321
+ npm test # Vitest: the client, the hooks and the components
322
+ npm run typecheck # also checks the types against the API fixtures
323
+ npm run check:package # build, 'use client' check, publint and Are The Types Wrong
324
+ npm run smoke -- http://localhost:8070 projekts # a live Odoo against the fixtures (read-only)
325
+ npm run playground # the example site on http://localhost:5173
326
+ npm run e2e # signup, email and confirmation in headless Edge (dev stack + Mailpit)
327
+ ```
328
+
329
+ **The fixtures.** The files in `test/fixtures/api` are the API's canonical answers, and the
330
+ `icrc_cms` addon's tests hold the live API to the same files. Copy them after an API change:
331
+ `npm run sync-fixtures -- <path of the icrc_cms addon>`.
332
+
333
+ **A local build in a website, before publishing:**
334
+ 1. Run `npm pack` here — it builds the package first (`prepack`), so it never packs a stale `dist/`.
335
+ 2. In the website, run `npm install ../icrc-cms-js/charleslemaux-cms-1.0.0-rc.1.tgz`.
336
+
337
+ ## Releasing
338
+
339
+ 1. `npm version 1.0.0-rc.2` (or `1.0.0`). It commits and tags.
340
+ 2. `npm login` (once). Then run `npm publish --tag next` for a release candidate, or `npm publish`
341
+ for a final version. `prepublishOnly` runs the type check, the tests and the package checks
342
+ first.
343
+ 3. `git push --follow-tags`.
344
+
345
+ ## License
346
+
347
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ CmsError: () => CmsError,
24
+ createCmsClient: () => createCmsClient,
25
+ isCmsError: () => isCmsError,
26
+ readNewsletterAction: () => readNewsletterAction
27
+ });
28
+ module.exports = __toCommonJS(src_exports);
29
+
30
+ // src/errors.ts
31
+ var CmsError = class extends Error {
32
+ constructor(status, code, message, retryAfter) {
33
+ super(message);
34
+ this.name = "CmsError";
35
+ this.status = status;
36
+ this.code = code;
37
+ if (retryAfter !== void 0) this.retryAfter = retryAfter;
38
+ }
39
+ };
40
+ function isCmsError(value) {
41
+ return value instanceof Error && value.name === "CmsError" && typeof value.status === "number" && typeof value.code === "string";
42
+ }
43
+
44
+ // src/client.ts
45
+ var API_PATH = "/cms/api/v1";
46
+ function createCmsClient(options) {
47
+ const baseUrl = normalizeBaseUrl(options.baseUrl);
48
+ const zone = typeof options.zone === "string" ? options.zone.trim() : "";
49
+ if (!zone) {
50
+ throw new TypeError('createCmsClient: `zone` is required, e.g. "projekts".');
51
+ }
52
+ const zonePath = `${API_PATH}/zones/${encodeURIComponent(zone)}`;
53
+ async function send(path, init, query) {
54
+ const fetchImpl = options.fetch ?? globalThis.fetch;
55
+ if (typeof fetchImpl !== "function") {
56
+ throw new TypeError("createCmsClient: no fetch available here. Pass `fetch` in the options.");
57
+ }
58
+ let response;
59
+ try {
60
+ response = await fetchImpl(buildUrl(baseUrl, path, query), init);
61
+ } catch (error) {
62
+ if (isAbortError(error) || init.signal?.aborted) throw error;
63
+ throw new CmsError(0, "network_error", `Could not reach ${baseUrl}: ${describeError(error)}`);
64
+ }
65
+ if (!response.ok) {
66
+ throw await errorFromResponse(response, init.signal);
67
+ }
68
+ try {
69
+ return await response.json();
70
+ } catch (error) {
71
+ if (isAbortError(error) || init.signal?.aborted) throw error;
72
+ throw new CmsError(response.status, "invalid_response", "The CMS did not answer with JSON.");
73
+ }
74
+ }
75
+ function read(path, query, signal) {
76
+ const extra = options.fetchOptions ?? {};
77
+ const headers = new Headers(extra.headers);
78
+ headers.set("Accept", "application/json");
79
+ return send(path, { ...extra, method: "GET", headers, signal: signal ?? extra.signal }, query);
80
+ }
81
+ function post(path, body) {
82
+ return send(path, {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
85
+ body: JSON.stringify(body),
86
+ referrerPolicy: "no-referrer"
87
+ });
88
+ }
89
+ return {
90
+ baseUrl,
91
+ zone,
92
+ getZone: (readOptions = {}) => read(zonePath, { lang: readOptions.lang }, readOptions.signal),
93
+ listArticles: (params = {}) => read(
94
+ `${zonePath}/articles`,
95
+ { lang: params.lang, category: params.category, tag: params.tag, page: params.page, limit: params.limit },
96
+ params.signal
97
+ ),
98
+ async getArticle(slug, readOptions = {}) {
99
+ if (typeof slug !== "string" || !slug) {
100
+ throw new TypeError("getArticle: `slug` is required.");
101
+ }
102
+ try {
103
+ return await read(
104
+ `${zonePath}/articles/${encodeURIComponent(slug)}`,
105
+ { lang: readOptions.lang },
106
+ readOptions.signal
107
+ );
108
+ } catch (error) {
109
+ if (isCmsError(error) && error.status === 404) return null;
110
+ throw error;
111
+ }
112
+ },
113
+ subscribe: (params) => post(`${zonePath}/newsletter/subscribe`, {
114
+ email: params.email,
115
+ lang: params.lang,
116
+ consent: params.consent,
117
+ captchaToken: params.captchaToken,
118
+ website: params.website ?? ""
119
+ }),
120
+ confirm: (token) => post(`${API_PATH}/newsletter/confirm`, { token }),
121
+ unsubscribe: (token) => post(`${API_PATH}/newsletter/unsubscribe`, { token })
122
+ };
123
+ }
124
+ function normalizeBaseUrl(value) {
125
+ const text = typeof value === "string" ? value.trim() : "";
126
+ let url;
127
+ try {
128
+ url = new URL(text);
129
+ } catch {
130
+ url = void 0;
131
+ }
132
+ if (!url || url.protocol !== "https:" && url.protocol !== "http:") {
133
+ throw new TypeError(
134
+ `createCmsClient: \`baseUrl\` must be an absolute http(s) address such as "https://b.projekts.com", not "${text}".`
135
+ );
136
+ }
137
+ return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
138
+ }
139
+ function buildUrl(baseUrl, path, query = {}) {
140
+ const search = new URLSearchParams();
141
+ for (const [key, value] of Object.entries(query)) {
142
+ if (value !== void 0 && value !== "") search.set(key, String(value));
143
+ }
144
+ const text = search.toString();
145
+ return `${baseUrl}${path}${text ? `?${text}` : ""}`;
146
+ }
147
+ async function errorFromResponse(response, signal) {
148
+ const retryAfter = parseRetryAfter(response.headers.get("Retry-After"));
149
+ let code = "http_error";
150
+ let message = `The CMS answered with the status ${response.status}.`;
151
+ try {
152
+ const data = await response.json();
153
+ const error = isRecord(data) && isRecord(data.error) ? data.error : void 0;
154
+ if (error && typeof error.code === "string") {
155
+ code = error.code;
156
+ if (typeof error.message === "string") message = error.message;
157
+ }
158
+ } catch (error) {
159
+ if (isAbortError(error) || signal?.aborted) throw error;
160
+ }
161
+ return new CmsError(response.status, code, message, retryAfter);
162
+ }
163
+ function parseRetryAfter(value) {
164
+ if (!value) return void 0;
165
+ const seconds = Number(value);
166
+ if (Number.isFinite(seconds)) return Math.max(0, Math.ceil(seconds));
167
+ const date = Date.parse(value);
168
+ return Number.isNaN(date) ? void 0 : Math.max(0, Math.ceil((date - Date.now()) / 1e3));
169
+ }
170
+ function isRecord(value) {
171
+ return typeof value === "object" && value !== null;
172
+ }
173
+ function isAbortError(error) {
174
+ return isRecord(error) && error.name === "AbortError";
175
+ }
176
+ function describeError(error) {
177
+ return error instanceof Error ? error.message : String(error);
178
+ }
179
+
180
+ // src/newsletter-action.ts
181
+ var ACTIONS = ["confirm", "unsubscribe"];
182
+ var TOKEN_RE = /^[A-Za-z0-9_-]{16,512}$/;
183
+ function readNewsletterAction(source) {
184
+ if (source == null) return null;
185
+ const params = toSearchParams(source);
186
+ let found = null;
187
+ for (const action of ACTIONS) {
188
+ const values = params.getAll(action);
189
+ if (values.length === 0) continue;
190
+ if (values.length > 1 || found) return null;
191
+ found = { action, token: values[0] ?? "" };
192
+ }
193
+ return found && TOKEN_RE.test(found.token) ? found : null;
194
+ }
195
+ function toSearchParams(source) {
196
+ if (typeof source === "string") {
197
+ const query = source.includes("?") ? source.slice(source.indexOf("?") + 1) : source;
198
+ return new URLSearchParams(query.split("#")[0]);
199
+ }
200
+ if (isSearchParams(source)) return source;
201
+ if (isUrl(source)) return source.searchParams;
202
+ const params = new URLSearchParams();
203
+ for (const [key, value] of Object.entries(source)) {
204
+ const values = Array.isArray(value) ? value : value === void 0 ? [] : [value];
205
+ for (const item of values) params.append(key, item);
206
+ }
207
+ return params;
208
+ }
209
+ function isSearchParams(value) {
210
+ return typeof value.getAll === "function" && typeof value.append === "function";
211
+ }
212
+ function isUrl(value) {
213
+ const url = value;
214
+ return typeof url.href === "string" && typeof url.searchParams === "object" && isSearchParams(url.searchParams);
215
+ }
216
+ // Annotate the CommonJS export names for ESM import in node:
217
+ 0 && (module.exports = {
218
+ CmsError,
219
+ createCmsClient,
220
+ isCmsError,
221
+ readNewsletterAction
222
+ });