@intelex-ia/sdk 0.6.1 → 0.7.0

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/README.md CHANGED
@@ -1,310 +1,406 @@
1
- # `@intelex-ia/sdk`
2
-
3
- SDK pra consumir a **Content API IntelexIA** em sites headless (Next.js, Astro etc.). Multiplataforma Fase 2b.
4
-
5
- > **O que você ganha:** `getArticles` / `getArticleBySlug` tipados, `mergePosts()` pra unir conteúdo antigo + novo do painel, `<ArticleJsonLd>` pra injetar schema JSON-LD, e `articleToMetadata` pra plugar no `generateMetadata` do Next.js App Router. Zero dependências (usa `fetch` global).
6
-
7
- ## Instalação rápida (recomendado)
8
-
9
- Se você está começando agora, use o CLI — ele detecta a estrutura do seu projeto e configura tudo automaticamente:
10
-
11
- ```bash
12
- npx @intelex-ia/cli init
13
- ```
14
-
15
- Cria os arquivos no lugar certo (App Router ou Pages Router, com ou sem `src/`), configura `.env.local`, gera um `INTELEXIA.md` completo na raiz e instala o SDK pra você.
16
-
17
- ## Instalação manual
18
-
19
- ```bash
20
- npm install @intelex-ia/sdk
21
- # ou pnpm add @intelex-ia/sdk / yarn add @intelex-ia/sdk
22
- ```
23
-
24
- ## Configuração
25
-
26
- 1. No painel da IntelexIA, **cadastre o site como "Headless (código)"** (vai pedir domain + URL e secret de revalidate).
27
- 2. Abra o site na lista → seção **"Tokens de API (Content API)"** → **Gerar token**. Você recebe `itx_…` (mostrado uma única vez).
28
- 3. Coloque em `.env.local`:
29
-
30
- ```bash
31
- INTELEXIA_API_TOKEN=itx_seu_token_aqui
32
- INTELEXIA_API_URL=https://api.innovarticles.intelexia.com.br # opcional, é o default
33
- INTELEXIA_REVALIDATE_SECRET=cole-o-mesmo-segredo-do-painel
34
- ```
35
-
36
- ## Três modos de integração
37
-
38
- Cada cliente tem uma situação diferente. Escolha o modo que faz sentido:
39
-
40
- | Modo | Quando usar | O que muda no `/blog` |
41
- |---|---|---|
42
- | **🔀 Additive (recomendado)** | Cliente já tem posts antigos e quer **adicionar** os do painel sem perder os antigos | `mergePosts()` une os dois feeds, dedupa por slug, ordena por data |
43
- | **🔁 Replace** | Cliente migrou tudo pro painel — só os novos contam | Substitui `getAllPosts()` por `client.getArticles()` |
44
- | **🆕 Isolated** | Cliente quer manter `/blog` antigo intacto e criar rota nova só pra IA | Cria `/blog/ia` separado, blog antigo zero alterado |
45
-
46
- ---
47
-
48
- ### Modo 1: Additive (cliente existente — caso mais comum)
49
-
50
- ```tsx
51
- // app/blog/page.tsx
52
- import { intelexia } from "@/lib/intelexia";
53
- import { mergePosts } from "@intelex-ia/sdk";
54
- import { posts as localPosts } from "@/lib/posts"; // seu array atual
55
- import Link from "next/link";
56
-
57
- export default async function BlogIndex() {
58
- // 1) Busca os artigos do painel
59
- const { items: remote } = await intelexia.getArticles({ limit: 50 });
60
-
61
- // 2) Mistura com os seus posts antigos
62
- const all = mergePosts({
63
- local: localPosts,
64
- remote,
65
- mapLocal: (p) => ({
66
- slug: p.slug,
67
- title: p.title,
68
- excerpt: p.excerpt,
69
- publishedAt: p.date,
70
- featuredImageUrl: null,
71
- category: p.category,
72
- }),
73
- // Opcional — defaults sensatos:
74
- // dedupe: "remote-wins" (painel substitui local se slug colide)
75
- // sortBy: "date-desc" (mais recente primeiro)
76
- });
77
-
78
- return (
79
- <ul>
80
- {all.map((p) => (
81
- <li key={p.slug}>
82
- <Link href={`/blog/${p.slug}`}>{p.title}</Link>
83
- <time>{new Date(p.publishedAt).toLocaleDateString("pt-BR")}</time>
84
- {p.source === "remote" && <span className="badge-ia">✨ IA</span>}
85
- </li>
86
- ))}
87
- </ul>
88
- );
89
- }
90
- ```
91
-
92
- Pro post individual, tenta o painel primeiro, cai pro local se 404:
93
-
94
- ```tsx
95
- // app/blog/[slug]/page.tsx
96
- import { intelexia } from "@/lib/intelexia";
97
- import { IntelexiaApiError } from "@intelex-ia/sdk";
98
- import { ArticleJsonLd, articleToMetadata } from "@intelex-ia/sdk/react";
99
- import { getPostBySlug } from "@/lib/posts";
100
- import { notFound } from "next/navigation";
101
-
102
- export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
103
- const { slug } = await params;
104
-
105
- // 1) Tenta painel
106
- try {
107
- const { article } = await intelexia.getArticleBySlug(slug);
108
- return (
109
- <article>
110
- <ArticleJsonLd article={article} />
111
- <h1>{article.title}</h1>
112
- <div dangerouslySetInnerHTML={{ __html: article.contentHtml ?? "" }} />
113
- </article>
114
- );
115
- } catch (err) {
116
- if (!(err instanceof IntelexiaApiError) || err.status !== 404) throw err;
117
- }
118
-
119
- // 2) Fallback: post antigo local
120
- const local = getPostBySlug(slug);
121
- if (!local) notFound();
122
- return (
123
- <article>
124
- <h1>{local.title}</h1>
125
- {/* renderiza o local do jeito que você já fazia */}
126
- </article>
127
- );
128
- }
129
- ```
130
-
131
- ### Modo 2: Replace (migração completa)
132
-
133
- ```tsx
134
- // app/blog/page.tsx
135
- import { intelexia } from "@/lib/intelexia";
136
-
137
- export default async function BlogIndex() {
138
- const { items } = await intelexia.getArticles({ limit: 20 });
139
- return (
140
- <ul>
141
- {items.map((a) => (
142
- <li key={a.id}>
143
- <Link href={`/blog/${a.slug}`}>{a.title}</Link>
144
- </li>
145
- ))}
146
- </ul>
147
- );
148
- }
149
- ```
150
-
151
- Pro post individual, sem fallback:
152
-
153
- ```tsx
154
- import { intelexia } from "@/lib/intelexia";
155
- import { IntelexiaApiError } from "@intelex-ia/sdk";
156
- import { ArticleJsonLd, articleToMetadata } from "@intelex-ia/sdk/react";
157
- import { notFound } from "next/navigation";
158
-
159
- async function fetchArticle(slug: string) {
160
- try {
161
- return await intelexia.getArticleBySlug(slug);
162
- } catch (err) {
163
- if (err instanceof IntelexiaApiError && err.status === 404) notFound();
164
- throw err;
165
- }
166
- }
167
-
168
- export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
169
- const { slug } = await params;
170
- const { article } = await fetchArticle(slug);
171
- return articleToMetadata(article);
172
- }
173
-
174
- export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
175
- const { slug } = await params;
176
- const { article } = await fetchArticle(slug);
177
- return (
178
- <article>
179
- <ArticleJsonLd article={article} />
180
- <h1>{article.title}</h1>
181
- <div dangerouslySetInnerHTML={{ __html: article.contentHtml ?? "" }} />
182
- </article>
183
- );
184
- }
185
- ```
186
-
187
- ### Modo 3: Isolated (rota nova, blog antigo intacto)
188
-
189
- Cria `app/blog/ia/page.tsx` e `app/blog/ia/[slug]/page.tsx` com o mesmo código do modo Replace, mas em rota separada (`/blog/ia` em vez de `/blog`). Zero alteração no blog existente.
190
-
191
- ---
192
-
193
- ## Cliente — `lib/intelexia.ts`
194
-
195
- ```ts
196
- import { createClient } from "@intelex-ia/sdk";
197
-
198
- export const intelexia = createClient({
199
- token: process.env.INTELEXIA_API_TOKEN!,
200
- // ISR: revalida a cada 60s; o webhook revalidate invalida sob demanda
201
- init: { next: { revalidate: 60, tags: ["intelexia-articles"] } },
202
- });
203
- ```
204
-
205
- ## Webhook de revalidate — `app/api/revalidate/route.ts`
206
-
207
- Quando um artigo publica/edita no painel, nosso worker faz `POST` no seu endpoint. Implemente assim:
208
-
209
- ```ts
210
- import { NextResponse } from "next/server";
211
- import { revalidatePath, revalidateTag } from "next/cache";
212
-
213
- export async function POST(req: Request) {
214
- const headerSecret = req.headers.get("x-revalidate-secret");
215
- const body = (await req.json().catch(() => ({}))) as {
216
- secret?: string;
217
- slug?: string | null;
218
- domain?: string;
219
- };
220
- const got = headerSecret ?? body.secret ?? "";
221
- if (!process.env.INTELEXIA_REVALIDATE_SECRET || got !== process.env.INTELEXIA_REVALIDATE_SECRET) {
222
- return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
223
- }
224
-
225
- if (body.slug) revalidatePath(`/blog/${body.slug}`);
226
- revalidatePath("/blog");
227
- revalidateTag("intelexia-articles");
228
-
229
- return NextResponse.json({ ok: true, revalidated: { slug: body.slug ?? null } });
230
- }
231
- ```
232
-
233
- > O worker envia o secret tanto no header `x-revalidate-secret` quanto no body, tem timeout de 15s e é **fire-and-forget** (se seu endpoint falhar, o artigo continua publicado e a ISR atualiza pelo `revalidate: 60`).
234
-
235
- ## API
236
-
237
- ### `createClient(config)`
238
-
239
- | Opção | Tipo | Default |
240
- | -------- | -------------------- | ------------------------------------------------- |
241
- | `token` | `string` (obrig.) | — |
242
- | `apiUrl` | `string` | `https://api.innovarticles.intelexia.com.br` |
243
- | `fetch` | `typeof fetch` | `globalThis.fetch` |
244
- | `init` | `RequestInit` | `undefined` (passe `{ next: { revalidate: N } }`) |
245
-
246
- ### `client.getArticles({ limit?, offset?, init? })`
247
-
248
- Resposta: `{ ok, mode, lifecycle, count, items: ArticleListItem[] }`.
249
-
250
- ### `client.getArticleBySlug(slug, { init? })`
251
-
252
- Resposta: `{ ok, mode, lifecycle, article: Article }`. **404 → joga `IntelexiaApiError`** (use `try/catch` + `notFound()`).
253
-
254
- ### `mergePosts({ local, remote, mapLocal, dedupe?, sortBy? })` 🆕 0.2.0
255
-
256
- | Opção | Tipo | Default |
257
- | ---------- | ------------------------------------------------- | ---------------- |
258
- | `local` | `readonly TLocal[]` (qualquer shape) | — |
259
- | `remote` | `readonly ArticleListItem[]` | — |
260
- | `mapLocal` | `(post: TLocal) => Omit<UnifiedPost,"source"> \| null` | — |
261
- | `dedupe` | `"remote-wins" \| "local-wins" \| "throw"` | `"remote-wins"` |
262
- | `sortBy` | `"date-desc" \| "date-asc" \| "none"` | `"date-desc"` |
263
-
264
- Retorna `UnifiedPost[]` com `source: "local" | "remote"`. `mapLocal` retornando `null` filtra o post (útil pra esconder rascunhos). Slugs duplicados resolvidos pela estratégia `dedupe`. Veja exemplo no Modo Additive acima.
265
-
266
- ### `mode: "full" | "plain"`
267
-
268
- - **`full`** (sites `active`/`paused`): inclui schema JSON-LD, metaTitle/Description, keyword(s), interlinking interno no `contentHtml`.
269
- - **`plain`** (site `cancelled`): só conteúdo cru — schema/meta/keywords vêm `null`, interlinking removido. O `ArticleJsonLd` e o `articleToMetadata` lidam graciosamente (omitem o que não tem).
270
-
271
- A decisão é **server-side e não-negociável** — o cliente não escolhe o modo.
272
-
273
- ### Helpers `/react`
274
-
275
- - `<ArticleJsonLd article={article} />` — renderiza `<script type="application/ld+json">` com o `schemaJson`. No-op se ausente.
276
- - `articleToMetadata(article)` → objeto compatível com o `Metadata` do Next.js App Router (title, description, keywords, openGraph, twitter, customMetaTags como `other`).
277
-
278
- ## Erros
279
-
280
- ```ts
281
- import { IntelexiaApiError, MergeConflictError } from "@intelex-ia/sdk";
282
-
283
- try {
284
- await intelexia.getArticleBySlug("post-inexistente");
285
- } catch (err) {
286
- if (err instanceof IntelexiaApiError) {
287
- console.error(err.status, err.message); // 404, "Artigo não encontrado."
288
- }
289
- }
290
-
291
- try {
292
- mergePosts({ local, remote, mapLocal, dedupe: "throw" });
293
- } catch (err) {
294
- if (err instanceof MergeConflictError) {
295
- console.error(`Conflito de slug: ${err.slug}`);
296
- }
297
- }
298
- ```
299
-
300
- ## Compatibilidade
301
-
302
- - **Node** 18+ (fetch nativo), Edge Runtime, Browser.
303
- - **React** 18+ (peer dep opcional — só pros helpers `/react`).
304
- - **TypeScript:** tipos publicados no `dist`.
305
- - **Frameworks:** Next.js (App Router e Pages Router), Astro, Remix, SvelteKit, Vite, qualquer JS.
306
-
307
- ## Changelog
308
-
309
- - **0.2.0** — adiciona `mergePosts()` + tipo `UnifiedPost` + `MergeConflictError` (3 modos de integração).
310
- - **0.1.0** — release inicial: `createClient`, `getArticles`, `getArticleBySlug`, `<ArticleJsonLd>`, `articleToMetadata`.
1
+ # `@intelex-ia/sdk`
2
+
3
+ SDK pra consumir a **Content API IntelexIA** em sites headless (Next.js, Astro etc.). Multiplataforma Fase 2b.
4
+
5
+ > **O que você ganha:** `getArticles` / `getArticleBySlug` tipados, `mergePosts()` pra unir conteúdo antigo + novo do painel, `<ArticleJsonLd>` pra injetar schema JSON-LD, e `articleToMetadata` pra plugar no `generateMetadata` do Next.js App Router. Zero dependências (usa `fetch` global).
6
+
7
+ ## Instalação rápida (recomendado)
8
+
9
+ Se você está começando agora, use o CLI — ele detecta a estrutura do seu projeto e configura tudo automaticamente:
10
+
11
+ ```bash
12
+ npx @intelex-ia/cli init
13
+ ```
14
+
15
+ Cria os arquivos no lugar certo (App Router ou Pages Router, com ou sem `src/`), configura `.env.local`, gera um `INTELEXIA.md` completo na raiz e instala o SDK pra você.
16
+
17
+ ## Instalação manual
18
+
19
+ ```bash
20
+ npm install @intelex-ia/sdk
21
+ # ou pnpm add @intelex-ia/sdk / yarn add @intelex-ia/sdk
22
+ ```
23
+
24
+ ## Configuração
25
+
26
+ 1. No painel da IntelexIA, **cadastre o site como "Headless (código)"** (vai pedir domain + URL e secret de revalidate).
27
+ 2. Abra o site na lista → seção **"Tokens de API (Content API)"** → **Gerar token**. Você recebe `itx_…` (mostrado uma única vez).
28
+ 3. Coloque em `.env.local`:
29
+
30
+ ```bash
31
+ INTELEXIA_API_TOKEN=itx_seu_token_aqui
32
+ INTELEXIA_API_URL=https://api.innovarticles.intelexia.com.br # opcional, é o default
33
+ INTELEXIA_REVALIDATE_SECRET=cole-o-mesmo-segredo-do-painel
34
+ ```
35
+
36
+ ## Três modos de integração
37
+
38
+ Cada cliente tem uma situação diferente. Escolha o modo que faz sentido:
39
+
40
+ | Modo | Quando usar | O que muda no `/blog` |
41
+ |---|---|---|
42
+ | **🔀 Additive (recomendado)** | Cliente já tem posts antigos e quer **adicionar** os do painel sem perder os antigos | `mergePosts()` une os dois feeds, dedupa por slug, ordena por data |
43
+ | **🔁 Replace** | Cliente migrou tudo pro painel — só os novos contam | Substitui `getAllPosts()` por `client.getArticles()` |
44
+ | **🆕 Isolated** | Cliente quer manter `/blog` antigo intacto e criar rota nova só pra IA | Cria `/blog/ia` separado, blog antigo zero alterado |
45
+
46
+ ---
47
+
48
+ ### Modo 1: Additive (cliente existente — caso mais comum)
49
+
50
+ ```tsx
51
+ // app/blog/page.tsx
52
+ import { intelexia } from "@/lib/intelexia";
53
+ import { mergePosts } from "@intelex-ia/sdk";
54
+ import { posts as localPosts } from "@/lib/posts"; // seu array atual
55
+ import Link from "next/link";
56
+
57
+ export default async function BlogIndex() {
58
+ // 1) Busca os artigos do painel
59
+ const { items: remote } = await intelexia.getArticles({ limit: 50 });
60
+
61
+ // 2) Mistura com os seus posts antigos
62
+ const all = mergePosts({
63
+ local: localPosts,
64
+ remote,
65
+ mapLocal: (p) => ({
66
+ slug: p.slug,
67
+ title: p.title,
68
+ excerpt: p.excerpt,
69
+ publishedAt: p.date,
70
+ featuredImageUrl: null,
71
+ category: p.category,
72
+ }),
73
+ // Opcional — defaults sensatos:
74
+ // dedupe: "remote-wins" (painel substitui local se slug colide)
75
+ // sortBy: "date-desc" (mais recente primeiro)
76
+ });
77
+
78
+ return (
79
+ <ul>
80
+ {all.map((p) => (
81
+ <li key={p.slug}>
82
+ <Link href={`/blog/${p.slug}`}>{p.title}</Link>
83
+ <time>{new Date(p.publishedAt).toLocaleDateString("pt-BR")}</time>
84
+ {p.source === "remote" && <span className="badge-ia">✨ IA</span>}
85
+ </li>
86
+ ))}
87
+ </ul>
88
+ );
89
+ }
90
+ ```
91
+
92
+ Pro post individual, tenta o painel primeiro, cai pro local se 404:
93
+
94
+ ```tsx
95
+ // app/blog/[slug]/page.tsx
96
+ import { intelexia } from "@/lib/intelexia";
97
+ import { IntelexiaApiError } from "@intelex-ia/sdk";
98
+ import { ArticleJsonLd, ArticleBody, AuthorBar, AuthorSignature, articleToMetadata } from "@intelex-ia/sdk/react";
99
+ import { getPostBySlug } from "@/lib/posts";
100
+ import { notFound } from "next/navigation";
101
+
102
+ export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
103
+ const { slug } = await params;
104
+
105
+ // 1) Tenta painel
106
+ try {
107
+ const { article } = await intelexia.getArticleBySlug(slug);
108
+ return (
109
+ <article>
110
+ <ArticleJsonLd article={article} />
111
+ <h1>{article.title}</h1>
112
+ <AuthorBar article={article} empresa="Acme" />
113
+ <ArticleBody article={article} />
114
+ <AuthorSignature article={article} empresa="Acme" />
115
+ </article>
116
+ );
117
+ } catch (err) {
118
+ if (!(err instanceof IntelexiaApiError) || err.status !== 404) throw err;
119
+ }
120
+
121
+ // 2) Fallback: post antigo local
122
+ const local = getPostBySlug(slug);
123
+ if (!local) notFound();
124
+ return (
125
+ <article>
126
+ <h1>{local.title}</h1>
127
+ {/* renderiza o local do jeito que você já fazia */}
128
+ </article>
129
+ );
130
+ }
131
+ ```
132
+
133
+ ### Modo 2: Replace (migração completa)
134
+
135
+ ```tsx
136
+ // app/blog/page.tsx
137
+ import { intelexia } from "@/lib/intelexia";
138
+
139
+ export default async function BlogIndex() {
140
+ const { items } = await intelexia.getArticles({ limit: 20 });
141
+ return (
142
+ <ul>
143
+ {items.map((a) => (
144
+ <li key={a.id}>
145
+ <Link href={`/blog/${a.slug}`}>{a.title}</Link>
146
+ </li>
147
+ ))}
148
+ </ul>
149
+ );
150
+ }
151
+ ```
152
+
153
+ Pro post individual, sem fallback:
154
+
155
+ ```tsx
156
+ import { intelexia } from "@/lib/intelexia";
157
+ import { IntelexiaApiError } from "@intelex-ia/sdk";
158
+ import { ArticleJsonLd, ArticleBody, AuthorBar, AuthorSignature, articleToMetadata } from "@intelex-ia/sdk/react";
159
+ import { notFound } from "next/navigation";
160
+
161
+ async function fetchArticle(slug: string) {
162
+ try {
163
+ return await intelexia.getArticleBySlug(slug);
164
+ } catch (err) {
165
+ if (err instanceof IntelexiaApiError && err.status === 404) notFound();
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
171
+ const { slug } = await params;
172
+ const { article } = await fetchArticle(slug);
173
+ return articleToMetadata(article);
174
+ }
175
+
176
+ export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
177
+ const { slug } = await params;
178
+ const { article } = await fetchArticle(slug);
179
+ return (
180
+ <article>
181
+ <ArticleJsonLd article={article} />
182
+ <h1>{article.title}</h1>
183
+ <AuthorBar article={article} empresa="Acme" />
184
+ <ArticleBody article={article} />
185
+ <AuthorSignature article={article} empresa="Acme" />
186
+ </article>
187
+ );
188
+ }
189
+ ```
190
+
191
+ ### Modo 3: Isolated (rota nova, blog antigo intacto)
192
+
193
+ Cria `app/blog/ia/page.tsx` e `app/blog/ia/[slug]/page.tsx` com o mesmo código do modo Replace, mas em rota separada (`/blog/ia` em vez de `/blog`). Zero alteração no blog existente.
194
+
195
+ ---
196
+
197
+ ## Cliente — `lib/intelexia.ts`
198
+
199
+ ```ts
200
+ import { createClient } from "@intelex-ia/sdk";
201
+
202
+ export const intelexia = createClient({
203
+ token: process.env.INTELEXIA_API_TOKEN!,
204
+ // ISR: revalida a cada 60s; o webhook revalidate invalida sob demanda
205
+ init: { next: { revalidate: 60, tags: ["intelexia-articles"] } },
206
+ });
207
+ ```
208
+
209
+ ## Webhook de revalidate — `app/api/revalidate/route.ts`
210
+
211
+ Quando um artigo publica/edita no painel, nosso worker faz `POST` no seu endpoint. Implemente assim:
212
+
213
+ ```ts
214
+ import { NextResponse } from "next/server";
215
+ import { revalidatePath, revalidateTag } from "next/cache";
216
+
217
+ export async function POST(req: Request) {
218
+ const headerSecret = req.headers.get("x-revalidate-secret");
219
+ const body = (await req.json().catch(() => ({}))) as {
220
+ secret?: string;
221
+ slug?: string | null;
222
+ domain?: string;
223
+ };
224
+ const got = headerSecret ?? body.secret ?? "";
225
+ if (!process.env.INTELEXIA_REVALIDATE_SECRET || got !== process.env.INTELEXIA_REVALIDATE_SECRET) {
226
+ return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
227
+ }
228
+
229
+ if (body.slug) revalidatePath(`/blog/${body.slug}`);
230
+ revalidatePath("/blog");
231
+ revalidateTag("intelexia-articles");
232
+
233
+ return NextResponse.json({ ok: true, revalidated: { slug: body.slug ?? null } });
234
+ }
235
+ ```
236
+
237
+ > O worker envia o secret tanto no header `x-revalidate-secret` quanto no body, tem timeout de 15s e é **fire-and-forget** (se seu endpoint falhar, o artigo continua publicado e a ISR atualiza pelo `revalidate: 60`).
238
+
239
+ ## API
240
+
241
+ ### `createClient(config)`
242
+
243
+ | Opção | Tipo | Default |
244
+ | -------- | -------------------- | ------------------------------------------------- |
245
+ | `token` | `string` (obrig.) | — |
246
+ | `apiUrl` | `string` | `https://api.innovarticles.intelexia.com.br` |
247
+ | `fetch` | `typeof fetch` | `globalThis.fetch` |
248
+ | `init` | `RequestInit` | `undefined` (passe `{ next: { revalidate: N } }`) |
249
+
250
+ ### `client.getArticles({ limit?, offset?, init? })`
251
+
252
+ Resposta: `{ ok, mode, lifecycle, count, items: ArticleListItem[] }`.
253
+
254
+ ### `client.getArticleBySlug(slug, { init? })`
255
+
256
+ Resposta: `{ ok, mode, lifecycle, article: Article }`. **404 → joga `IntelexiaApiError`** (use `try/catch` + `notFound()`).
257
+
258
+ ### `mergePosts({ local, remote, mapLocal, dedupe?, sortBy? })` 🆕 0.2.0
259
+
260
+ | Opção | Tipo | Default |
261
+ | ---------- | ------------------------------------------------- | ---------------- |
262
+ | `local` | `readonly TLocal[]` (qualquer shape) | — |
263
+ | `remote` | `readonly ArticleListItem[]` | — |
264
+ | `mapLocal` | `(post: TLocal) => Omit<UnifiedPost,"source"> \| null` | — |
265
+ | `dedupe` | `"remote-wins" \| "local-wins" \| "throw"` | `"remote-wins"` |
266
+ | `sortBy` | `"date-desc" \| "date-asc" \| "none"` | `"date-desc"` |
267
+
268
+ Retorna `UnifiedPost[]` com `source: "local" | "remote"`. `mapLocal` retornando `null` filtra o post (útil pra esconder rascunhos). Slugs duplicados resolvidos pela estratégia `dedupe`. Veja exemplo no Modo Additive acima.
269
+
270
+ ### `mode: "full" | "plain"`
271
+
272
+ - **`full`** (sites `active`/`paused`): inclui schema JSON-LD, metaTitle/Description, keyword(s), interlinking interno no `contentHtml`.
273
+ - **`plain`** (site `cancelled`): só conteúdo cru — schema/meta/keywords vêm `null`, interlinking removido. O `ArticleJsonLd` e o `articleToMetadata` lidam graciosamente (omitem o que não tem).
274
+
275
+ A decisão é **server-side e não-negociável** — o cliente não escolhe o modo.
276
+
277
+ ### Helpers `/react`
278
+
279
+ - `<ArticleJsonLd article={article} />`: renderiza `<script type="application/ld+json">` com o `schemaJson`. No-op se ausente.
280
+ - `inteligenciaDoArtigo(article)`: a camada de SEO do artigo, venha **aninhada** em `article.intelligence` (o que a Content API manda em `full`) ou na raiz (payload antigo). É a regra única que o `ArticleJsonLd`, o `articleToMetadata` e o `autoriaDoArtigo` usam.
281
+
282
+ > 🚨 **Se você lê a inteligência à mão, leia pelo helper.** Até a 0.6.0 o
283
+ > `ArticleJsonLd` e o `articleToMetadata` liam `article.schemaJson` e
284
+ > `article.metaDescription` da RAIZ, onde a Content API não entrega nada no modo
285
+ > `full`. O efeito, medido no ar em 26/10/2026: página com **zero** blocos
286
+ > `application/ld+json` e **sem** meta description. Nada quebrava e nada
287
+ > aparecia no console. Desde a 0.7.0 os três helpers leem pelo
288
+ > `inteligenciaDoArtigo()`, com queda para a raiz.
289
+ - `articleToMetadata(article)`: objeto compatível com o `Metadata` do Next.js App Router (title, description, keywords, openGraph, twitter, customMetaTags como `other`).
290
+ - `<ArticleBody article={article} />`: o corpo do artigo, vestido. **Use este em vez de injetar o `contentHtml` numa div.**
291
+ - `<AuthorBar article={article} empresa="Acme" />`: a barra de autoria, logo abaixo do `<h1>`.
292
+ - `<AuthorSignature article={article} empresa="Acme" />`: a assinatura do fim do artigo, com o selo de apoio de IA.
293
+
294
+ #### Por que não injetar o `contentHtml` numa div
295
+
296
+ O corpo chega como HTML semântico **pelado**: `<h2>`, `<ul>`, `<table>`, sem
297
+ classe. No WordPress funciona, porque o tema veste por tag. Num site Tailwind
298
+ não: o `@tailwind base` zera `list-style`, zera tamanho e peso dos seis títulos
299
+ e deixa a tabela sem borda. Medido no preflight 3.4 real, sem folha por cima, o
300
+ `<h2>` sai a 16px peso 400 e a lista sem marcador. Isso não é "sem estilo", é
301
+ pior que sem estilo, e foi o que manteve o blog de um cliente como parede de
302
+ texto por semanas.
303
+
304
+ O `<ArticleBody>` faz três coisas que ninguém precisa repetir:
305
+
306
+ 1. injeta a folha estrutural, escopada na classe `ia-artigo`, que ele mesmo põe
307
+ na div. O que você passar em `className` entra **ao lado** do gancho, nunca
308
+ no lugar dele;
309
+ 2. devolve o que o preflight tira, com especificidade de 0,0,1 nas regras que
310
+ disputam com o reset e 0,0,0 no resto. Regra sua escrita por classe
311
+ (`.meu-blog h2`) ganha da nossa, sempre;
312
+ 3. remove a assinatura que o motor embute no `contentHtml`, para ela não sair
313
+ duas vezes quando a página usa `<AuthorSignature>`. Por isso os dois andam
314
+ juntos: `<ArticleBody>` sozinho deixa a página sem autor;
315
+ 4. embrulha cada `<table>` num `<div class="tabela-rolante">`. O navegador
316
+ ignora `overflow-x` em caixa de tabela, então sem o contêiner a tabela larga
317
+ faz a **página inteira** rolar de lado no celular: medido a 375px, 494
318
+ contra 375. O embrulho é idempotente e só sai com a folha, porque é ela que
319
+ traz a regra do contêiner.
320
+
321
+ `comFolha={false}` dispensa a folha. É obrigatório em **Tailwind v4 cujo corpo
322
+ já é vestido pelo `@tailwindcss/typography` instalado**, ou por utilitário no
323
+ wrapper: ali o site emite tudo dentro de `@layer`, camada é decidida antes de
324
+ especificidade, e a nossa folha, que fica fora de camada, ganharia de quem fez
325
+ certo. Em Tailwind v3 não existe esse caso.
326
+
327
+ A folha também pode ser importada solta, em `CSS_CORPO_DO_ARTIGO`, com a classe
328
+ em `CLASSE_DO_CORPO`.
329
+
330
+ #### A folha lê a marca do site, sem configuração
331
+
332
+ As cores e a fonte de título saem de variável, e a variável LÊ o token que o
333
+ site já tem, com queda para o que a folha fazia antes:
334
+
335
+ | Variável da folha | Token do site | Queda |
336
+ |---|---|---|
337
+ | `--ia-cor-titulo` | `--primary` | `inherit` |
338
+ | `--ia-cor-destaque` | `--accent` | `currentColor` |
339
+ | `--ia-cor-borda` | `--border` | `currentColor` |
340
+ | `--ia-fonte-titulo` | nenhum | `inherit` |
341
+
342
+ Site que declara o token como **trio de HSL** (`--primary: 222 47% 11%`, o
343
+ jeito do shadcn) cai na queda e fica igual a antes, nunca pior. Medido nos três
344
+ casos em 26/10/2026, com o preflight 3.4.19 real: sem token, o `h2` herda a cor
345
+ do corpo; com `#003366`, sai `#003366`; com o trio, herda de novo, e a borda da
346
+ tabela segue em 1px.
347
+
348
+ Para discordar, redeclare a variável na sua folha, sem tocar no pacote:
349
+
350
+ ```css
351
+ .meu-blog .ia-artigo { --ia-cor-titulo: #003366; --ia-fonte-titulo: "Fraunces", serif; }
352
+ ```
353
+
354
+ A FAQ em `<details>` sai em acordeão, com o marcador do navegador escondido,
355
+ triângulo próprio e giro no estado aberto.
356
+
357
+ #### Site preso numa versão antiga do SDK
358
+
359
+ Se o site ainda não pode subir para a 0.7.0, `folhaParaSite()` devolve a MESMA
360
+ folha reescrita para o CSS do próprio site, numa classe dele:
361
+
362
+ ```ts
363
+ import { folhaParaSite } from "@intelex-ia/sdk/react";
364
+ console.log(folhaParaSite([".meu-artigo"])); // cole no globals.css
365
+ console.log(folhaParaSite([".a", ".b"])); // duas classes de uma vez
366
+ ```
367
+
368
+ O seletor sai por classe, `0,1,1`, então ganha do reset de tipo em qualquer
369
+ versão do Tailwind, com ou sem camada. E quando o site migrar para o
370
+ `<ArticleBody>` nada conflita: `0,1,1` continua ganhando do `0,0,1` do SDK, ou
371
+ seja a folha do site continua mandando no que ela já decidia.
372
+
373
+ ## Erros
374
+
375
+ ```ts
376
+ import { IntelexiaApiError, MergeConflictError } from "@intelex-ia/sdk";
377
+
378
+ try {
379
+ await intelexia.getArticleBySlug("post-inexistente");
380
+ } catch (err) {
381
+ if (err instanceof IntelexiaApiError) {
382
+ console.error(err.status, err.message); // 404, "Artigo não encontrado."
383
+ }
384
+ }
385
+
386
+ try {
387
+ mergePosts({ local, remote, mapLocal, dedupe: "throw" });
388
+ } catch (err) {
389
+ if (err instanceof MergeConflictError) {
390
+ console.error(`Conflito de slug: ${err.slug}`);
391
+ }
392
+ }
393
+ ```
394
+
395
+ ## Compatibilidade
396
+
397
+ - **Node** 18+ (fetch nativo), Edge Runtime, Browser.
398
+ - **React** 18+ (peer dep opcional — só pros helpers `/react`).
399
+ - **TypeScript:** tipos publicados no `dist`.
400
+ - **Frameworks:** Next.js (App Router e Pages Router), Astro, Remix, SvelteKit, Vite, qualquer JS.
401
+
402
+ ## Changelog
403
+
404
+ - **0.7.0**: adiciona `<ArticleBody>`, `<AuthorBar>`, `<AuthorSignature>`, `CSS_CORPO_DO_ARTIGO`, `CLASSE_DO_CORPO`, `envolverTabelas` e `folhaParaSite`. O corpo do artigo passa a sair vestido, sem depender de o site lembrar de uma folha. A data da barra passa a sair no fuso de Brasília, e não no do servidor.
405
+ - **0.2.0** — adiciona `mergePosts()` + tipo `UnifiedPost` + `MergeConflictError` (3 modos de integração).
406
+ - **0.1.0** — release inicial: `createClient`, `getArticles`, `getArticleBySlug`, `<ArticleJsonLd>`, `articleToMetadata`.