@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 +21 -0
- package/README.md +347 -0
- package/dist/index.cjs +222 -0
- package/dist/index.d.cts +192 -0
- package/dist/index.d.ts +192 -0
- package/dist/index.js +192 -0
- package/dist/react.cjs +665 -0
- package/dist/react.d.cts +268 -0
- package/dist/react.d.ts +268 -0
- package/dist/react.js +632 -0
- package/package.json +87 -0
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
|
+
});
|