@mapled/next 0.6.0 → 0.7.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/README.md +299 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +34 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,18 +4,215 @@ Read published [Mapled](https://mapled.io) content in a Next.js site — a deliv
|
|
|
4
4
|
|
|
5
5
|
Mapled is a hosted headless CMS built for sites created with AI. Your agent (or you) models collections and fills in content; this package is how the site reads what's published.
|
|
6
6
|
|
|
7
|
+
- [Install](#install)
|
|
8
|
+
- [Integrate by hand](#integrate-by-hand) — the whole integration step by step, no AI agent needed
|
|
9
|
+
- Reference: [Read published content](#read-published-content) • [Rich text](#rich-text) • [Images](#images) • [Refresh on publish](#refresh-on-publish) • [Preview drafts](#preview-drafts) • [Forms](#forms) • [Live data](#live-data) • [Without Next.js: the HTTP API](#without-nextjs-the-http-api) • [API](#api)
|
|
10
|
+
|
|
7
11
|
## Install
|
|
8
12
|
|
|
9
13
|
```bash
|
|
10
14
|
npm install @mapled/next
|
|
11
15
|
```
|
|
12
16
|
|
|
13
|
-
Grab the delivery key in Mapled: project → Integrations → **Your site**, and put it in your site's env:
|
|
17
|
+
Grab the delivery key in Mapled: project settings (the gear in the project's header) → **Integrations** → **Your site**, and put it in your site's env:
|
|
14
18
|
|
|
15
19
|
```bash
|
|
16
20
|
MAPLED_KEY=mk_live_…
|
|
17
21
|
```
|
|
18
22
|
|
|
23
|
+
## Integrate by hand
|
|
24
|
+
|
|
25
|
+
An AI agent connected through [`@mapled/mcp`](https://www.npmjs.com/package/@mapled/mcp) does all of this for you. Nothing in it needs one, though: this is the same integration, step by step — from a Next.js site with hardcoded content to a site its editors run from Mapled. Plan on an hour for a small site.
|
|
26
|
+
|
|
27
|
+
You need a Mapled project where you can change the structure (the builder plan), a Next.js 14+ site on the App Router, and Node 18 or newer. The example is a site with a homepage and a blog; the keys (`homepage`, `articles`) are whatever you choose in step 1.
|
|
28
|
+
|
|
29
|
+
### 1. Model the content
|
|
30
|
+
|
|
31
|
+
In Mapled, open the project and press the wrench in the header (**Structure**) → **Collections**. Create a **single** for every one-off part of the site (`homepage`, `footer`) and a **collection** for every list of alike things (`articles`, `team`), then **Add field** for each text, image and link the code hardcodes today.
|
|
32
|
+
|
|
33
|
+
- Keys are what the code reads — `getRecords("articles")`, `data.title`. A key can be renamed later and the old one keeps working as an alias, but it is a migration: choose them with care.
|
|
34
|
+
- A collection whose records have pages of their own needs a **Slug** field — `getRecordBySlug` finds records by it.
|
|
35
|
+
- Write a **Help text** for every field. Editors see it under the field; write it for someone who has never seen the code.
|
|
36
|
+
- Mark a field **Sensitive** when editors keep it but the site must never receive it.
|
|
37
|
+
|
|
38
|
+
### 2. Fill it in and publish once
|
|
39
|
+
|
|
40
|
+
Open **Content**, move the hardcoded texts and images into records, then **Review & publish**. The site only ever reads published releases: before the first publish the delivery API answers 404 “Nothing published yet.” — `getSingle` and `getRecord` read that as `null`, `getRecords` throws.
|
|
41
|
+
|
|
42
|
+
### 3. Link the repository and generate types
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx @mapled/cli auth login
|
|
46
|
+
npx @mapled/cli project link
|
|
47
|
+
npx @mapled/cli types generate
|
|
48
|
+
npx @mapled/cli schema pull
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Your browser opens Mapled, you pick the project and authorize; `project link` writes `mapled.json`, `types generate` writes `mapled-types.ts` — an interface per collection and single, tied together by `MapledSchema` — and `schema pull` records the schema the site is built against in `mapled/schema.json`, which `schema diff` and `doctor` compare with Mapled later. ([`@mapled/cli`](https://www.npmjs.com/package/@mapled/cli) has the details of every command used here.)
|
|
52
|
+
|
|
53
|
+
### 4. Create the client
|
|
54
|
+
|
|
55
|
+
Install the package and set `MAPLED_KEY` ([Install](#install)) in `.env.local` and in your hosting's environment. Then one client for the whole site:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// lib/mapled.ts
|
|
59
|
+
import { createClient } from "@mapled/next";
|
|
60
|
+
import type { MapledSchema } from "../mapled-types";
|
|
61
|
+
|
|
62
|
+
export const mapled = createClient<MapledSchema>({ key: process.env.MAPLED_KEY! });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
With `MapledSchema` every read is typed by its key, and a misspelled collection or field is a build error.
|
|
66
|
+
|
|
67
|
+
### 5. Replace the hardcoded content with reads
|
|
68
|
+
|
|
69
|
+
Read in Server Components — no client-side fetching, no loading states. A single:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
// app/page.tsx
|
|
73
|
+
import { assetUrl } from "@mapled/next";
|
|
74
|
+
import { mapled } from "../lib/mapled";
|
|
75
|
+
|
|
76
|
+
export default async function Home() {
|
|
77
|
+
const home = await mapled.getSingle("homepage");
|
|
78
|
+
if (!home) return null; // nothing published yet
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<main>
|
|
82
|
+
<h1>{home.data.headline}</h1>
|
|
83
|
+
<p>{home.data.intro}</p>
|
|
84
|
+
{home.data.cover ? <img src={assetUrl(home.data.cover, { width: 1280 })} alt="" /> : null}
|
|
85
|
+
</main>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A list:
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
// app/blog/page.tsx
|
|
94
|
+
import Link from "next/link";
|
|
95
|
+
import { mapled } from "../../lib/mapled";
|
|
96
|
+
|
|
97
|
+
export default async function Blog() {
|
|
98
|
+
const { records } = await mapled.getRecords("articles", { sort: "-date", limit: 20 });
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<ul>
|
|
102
|
+
{records.map((post) => (
|
|
103
|
+
<li key={post.id}>
|
|
104
|
+
<Link href={`/blog/${post.data.slug}`}>{post.data.title}</Link>
|
|
105
|
+
</li>
|
|
106
|
+
))}
|
|
107
|
+
</ul>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A page per record — new records get their page without a deploy:
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
// app/blog/[slug]/page.tsx
|
|
116
|
+
import { notFound } from "next/navigation";
|
|
117
|
+
import Markdown from "react-markdown";
|
|
118
|
+
import { mapled } from "../../../lib/mapled";
|
|
119
|
+
|
|
120
|
+
export async function generateStaticParams() {
|
|
121
|
+
const { records } = await mapled.getRecords("articles", { fields: ["slug"] });
|
|
122
|
+
return records.map((post) => ({ slug: post.data.slug }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export default async function Article({ params }: { params: Promise<{ slug: string }> }) {
|
|
126
|
+
const { slug } = await params;
|
|
127
|
+
const post = await mapled.getRecordBySlug("articles", slug);
|
|
128
|
+
if (!post) notFound();
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<article>
|
|
132
|
+
<h1>{post.data.title}</h1>
|
|
133
|
+
<Markdown>{post.data.body}</Markdown>
|
|
134
|
+
</article>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
(`params` is a Promise since Next.js 15; on 14 it is the plain object.) Rich text is Markdown — render it with a component such as `react-markdown`, never as raw HTML. Leave in the code what editors shouldn't touch: layout, navigation structure, anything that is logic rather than content.
|
|
140
|
+
|
|
141
|
+
### 6. Refresh the site on publish
|
|
142
|
+
|
|
143
|
+
Reads are cached (`revalidate: 3600`) and tagged; a webhook refreshes them the moment someone publishes.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
// app/api/mapled/revalidate/route.ts
|
|
147
|
+
import { createRevalidateHandler } from "@mapled/next/server";
|
|
148
|
+
|
|
149
|
+
export const POST = createRevalidateHandler({
|
|
150
|
+
secret: process.env.MAPLED_WEBHOOK_SECRET!,
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Deploy, then in Mapled → **Integrations** → **Your site** save `https://your-site.com/api/mapled/revalidate` as the **Revalidation webhook**, copy the **Signing secret** into `MAPLED_WEBHOOK_SECRET` (hosting env and `.env.local`), redeploy, and press **Send test ping**: the delivery shows up under **Recent deliveries** as Delivered. The URL has to be public — Mapled refuses addresses that resolve to a private network.
|
|
155
|
+
|
|
156
|
+
### 7. Preview drafts
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// app/api/mapled/preview/route.ts
|
|
160
|
+
import { createPreviewHandler } from "@mapled/next/server";
|
|
161
|
+
|
|
162
|
+
export const GET = createPreviewHandler();
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// app/api/mapled/preview/exit/route.ts
|
|
167
|
+
import { createExitPreviewHandler } from "@mapled/next/server";
|
|
168
|
+
|
|
169
|
+
export const GET = createExitPreviewHandler();
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The **Preview** button in Mapled opens the site at the origin of the webhook URL from step 6, on exactly this path — `/api/mapled/preview` — so keep the route there. It turns on Next.js draft mode, and every read of the client switches to live drafts, uncached, with no changes in your components.
|
|
173
|
+
|
|
174
|
+
### 8. Forms
|
|
175
|
+
|
|
176
|
+
A contact or signup form posts straight to Mapled; submissions land in **Forms**, with e-mail notifications and spam protection. Create the form once:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npx @mapled/cli forms create "Contact"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
and post to it from the site — see [Forms](#forms) for the component.
|
|
183
|
+
|
|
184
|
+
### 9. Live data
|
|
185
|
+
|
|
186
|
+
Orders, bookings, votes — records the site's backend writes and reads immediately, without Publish — go to collections created with the mode **Live data** and are reached with the server key: see [Live data](#live-data).
|
|
187
|
+
|
|
188
|
+
### 10. Record the integration
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
npx @mapled/cli scan --write
|
|
192
|
+
npx @mapled/cli bindings push
|
|
193
|
+
npx @mapled/cli md pull
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`scan` reads the code and writes `mapled/manifest.json`: which page reads which collection and field. `bindings push` sends it to Mapled, so editors see where a field appears and get a warning before a change would break the site (**Structure** → **Bindings**). `md pull` writes `MAPLED.md`, the guide for whoever works on the site next — person or AI agent. Commit `mapled.json`, `mapled-types.ts`, `mapled/schema.json`, `mapled/manifest.json` and `MAPLED.md`; never commit an env file.
|
|
197
|
+
|
|
198
|
+
### 11. Check everything
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
npx @mapled/cli doctor
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
It checks the integration from both sides — the repository and what Mapled sees of the deployed site. The list below is what an agent's setup has to pass before Mapled calls it complete; `doctor` answers all of it except the last two lines, which are yours to check:
|
|
205
|
+
|
|
206
|
+
- the site reads content from Mapled (“Site reads content — Last read …”);
|
|
207
|
+
- a publish refreshes it (“Publish webhook — Delivered …”);
|
|
208
|
+
- **Preview** in Mapled opens the site with drafts (“Preview on the site — Responds at …”);
|
|
209
|
+
- bindings are pushed and healthy, and the integration is in sync (“Bindings”, “Integration”);
|
|
210
|
+
- no env file or secret is tracked by git (“Secrets in git”);
|
|
211
|
+
- every field has a help text (**Structure** → **Collections**);
|
|
212
|
+
- the site builds.
|
|
213
|
+
|
|
214
|
+
Later, when the structure changes in Mapled, `npx @mapled/cli schema diff` says what changed and whether it breaks the site; after updating the code, run `types generate`, `scan --write`, `bindings push` and `md pull` again.
|
|
215
|
+
|
|
19
216
|
## Read published content
|
|
20
217
|
|
|
21
218
|
```ts
|
|
@@ -63,6 +260,16 @@ await mapled.getRecords("artcles"); // type error — no s
|
|
|
63
260
|
|
|
64
261
|
Without a schema the client behaves as before: any key, `Record<string, unknown>` data, or the type you name per call (`getRecords<Article>("articles")`). `createAppClient<MapledSchema>` types live data the same way — `db.create("orders", data)` wants an `Order`. `mapled doctor` tells you when the generated file is behind the schema.
|
|
65
262
|
|
|
263
|
+
### Renamed fields
|
|
264
|
+
|
|
265
|
+
When a field's key is renamed in Mapled, nothing breaks: the old key stays as an alias — delivered next to the new one, accepted in `filter`, `sort`, `fields`, `expand` and in live-data writes — until someone removes it in Field settings. Answers that still serve a previous key name it (`aliases: { products: { price: "amount" } }` on a list result), and outside production the client says so once per key:
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
[mapled] “price” in “products” was renamed to “amount”. The old key keeps working until its alias is removed in Mapled — use “amount” instead.
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Move the code to the new key, run `mapled types generate` (the old key is typed `@deprecated` until then), and remove the alias. In production the client stays silent.
|
|
272
|
+
|
|
66
273
|
### Pin a release
|
|
67
274
|
|
|
68
275
|
A page that makes several reads can pin them all to one release, so a publish landing mid-render can't mix two versions:
|
|
@@ -104,7 +311,9 @@ export const POST = createRevalidateHandler({
|
|
|
104
311
|
});
|
|
105
312
|
```
|
|
106
313
|
|
|
107
|
-
Then in Mapled (
|
|
314
|
+
Then in Mapled (**Integrations** → **Your site**) set the revalidation webhook to `https://your-site.com/api/mapled/revalidate` and copy the signing secret into `MAPLED_WEBHOOK_SECRET`. Every publish sends a signed ping; the handler verifies the `x-mapled-signature` HMAC and revalidates the `mapled` tags.
|
|
315
|
+
|
|
316
|
+
Each ping is tried up to three times (after 2 and 6 seconds, 5 seconds to answer); **Recent deliveries** keeps them for 30 days, and a failed one can be sent again from there. **Rotate secret** replaces the signing secret at once — deliveries fail until the site has the new one.
|
|
108
317
|
|
|
109
318
|
## Preview drafts
|
|
110
319
|
|
|
@@ -124,6 +333,64 @@ export const GET = createExitPreviewHandler();
|
|
|
124
333
|
|
|
125
334
|
The **Preview** button in Mapled opens your site through a one-time link: the route turns on Next.js draft mode, and every `getRecords`/`getRecord`/`getSingle` call automatically switches to live drafts, uncached. No code changes in your components — the client detects draft mode on its own. Sessions last two hours; `/api/mapled/preview/exit` leaves preview.
|
|
126
335
|
|
|
336
|
+
Mapled finds the site by the revalidation webhook's URL — preview opens at its origin, on `/api/mapled/preview` — so set the webhook up first and keep the route on that path.
|
|
337
|
+
|
|
338
|
+
## Forms
|
|
339
|
+
|
|
340
|
+
A form is created once — `npx @mapled/cli forms create "Contact"`, or by your AI agent — and gets a key (`contact`). The site posts submissions as flat JSON with the delivery key; they show up in Mapled under **Forms** right away, and the form's recipients — the project's owners, unless its settings name others — get an e-mail. Forms never wait for Publish.
|
|
341
|
+
|
|
342
|
+
```tsx
|
|
343
|
+
// app/contact/ContactForm.tsx
|
|
344
|
+
"use client";
|
|
345
|
+
|
|
346
|
+
import { useState, type FormEvent } from "react";
|
|
347
|
+
|
|
348
|
+
const CONSENT = "I agree to be contacted about my request.";
|
|
349
|
+
|
|
350
|
+
export function ContactForm() {
|
|
351
|
+
const [state, setState] = useState<"idle" | "sending" | "sent" | "failed">("idle");
|
|
352
|
+
|
|
353
|
+
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
354
|
+
event.preventDefault();
|
|
355
|
+
setState("sending");
|
|
356
|
+
const form = new FormData(event.currentTarget);
|
|
357
|
+
const res = await fetch("https://api.mapled.io/v1/delivery/forms/contact", {
|
|
358
|
+
method: "POST",
|
|
359
|
+
headers: { "content-type": "application/json", "x-mapled-key": process.env.NEXT_PUBLIC_MAPLED_KEY! },
|
|
360
|
+
body: JSON.stringify({
|
|
361
|
+
name: form.get("name"),
|
|
362
|
+
email: form.get("email"),
|
|
363
|
+
message: form.get("message"),
|
|
364
|
+
_gotcha: form.get("_gotcha"), // the honeypot: people leave it empty
|
|
365
|
+
_consent: form.get("consent") ? CONSENT : undefined,
|
|
366
|
+
_page: window.location.pathname,
|
|
367
|
+
}),
|
|
368
|
+
});
|
|
369
|
+
setState(res.ok ? "sent" : "failed");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (state === "sent") return <p>Thank you — we'll be in touch.</p>;
|
|
373
|
+
return (
|
|
374
|
+
<form onSubmit={submit}>
|
|
375
|
+
<input name="name" required />
|
|
376
|
+
<input name="email" type="email" required />
|
|
377
|
+
<textarea name="message" required />
|
|
378
|
+
<input name="_gotcha" tabIndex={-1} autoComplete="off" aria-hidden="true" style={{ display: "none" }} />
|
|
379
|
+
<label>
|
|
380
|
+
<input name="consent" type="checkbox" required /> {CONSENT}
|
|
381
|
+
</label>
|
|
382
|
+
<button disabled={state === "sending"}>Send</button>
|
|
383
|
+
{state === "failed" ? <p>That didn't go through. Try again in a minute.</p> : null}
|
|
384
|
+
</form>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
- The endpoint is open to browsers (CORS) and takes the **delivery key** — the one key that is safe in client code. Expose it as `NEXT_PUBLIC_MAPLED_KEY`; never the server key or the webhook secret. To keep even that out of the bundle, post from a Server Action with `MAPLED_KEY` instead — all visitors then share your server's address for the limit below.
|
|
390
|
+
- The body is one flat object: up to 50 fields of text (5,000 characters), numbers and booleans. Keys starting with `_` are instructions, not answers: `_gotcha` (a hidden field bots fill in — such submissions are accepted and filed as spam), `_consent` (the exact label of the consent checkbox the visitor ticked, kept as evidence), `_page` (where the form was), `_test: true` (a test submission: no e-mail, not counted).
|
|
391
|
+
- `201 { "ok": true }` on success. `400 VALIDATION_FAILED` for a body of another shape, `404` for an unknown key or form, `429 RATE_LIMITED` past 30 submissions a minute per form and address, `429 FORMS_LIMIT` when the project's monthly submissions are used up, `410 PROJECT_ARCHIVED` for an archived project.
|
|
392
|
+
- `npx @mapled/cli forms list` shows the project's forms and their URLs.
|
|
393
|
+
|
|
127
394
|
## Live data
|
|
128
395
|
|
|
129
396
|
Operational collections skip the publish cycle — saves take effect immediately. Your site's backend reads **and writes** them through the Application Data API with the project's **server key** (Mapled: Integrations → Your site). Server-side only; the key must never reach the browser.
|
|
@@ -145,12 +412,42 @@ await db.remove("orders", id);
|
|
|
145
412
|
|
|
146
413
|
The server key reaches only operational collections with access class `public`/`server` — editorial content, internal and sensitive collections stay out of reach, and it grants nothing on the Management API.
|
|
147
414
|
|
|
415
|
+
## Without Next.js: the HTTP API
|
|
416
|
+
|
|
417
|
+
The client is a thin layer over a plain HTTP API, so any site — or a build script — can read Mapled. The base URL is `https://api.mapled.io`; every request carries the delivery key in `x-mapled-key`.
|
|
418
|
+
|
|
419
|
+
| Request | Answer |
|
|
420
|
+
| --- | --- |
|
|
421
|
+
| `GET /v1/delivery/collections/{collection}/records` | `{ records: [{ id, data, expanded? }], total, release }` |
|
|
422
|
+
| `GET /v1/delivery/collections/{collection}/records/{id}` | `{ record, release }` — 404 when it isn't in the release |
|
|
423
|
+
| `GET /v1/delivery/collections/{collection}/records/by-slug/{slug}` | `{ record, release }` |
|
|
424
|
+
| `GET /v1/delivery/singles/{single}` | `{ record, release }` — `record` is `null` while the single is empty |
|
|
425
|
+
| `GET /v1/delivery/release` | `{ release, publishedAt }` — the release being served now; `null`s before the first publish |
|
|
426
|
+
| `POST /v1/delivery/forms/{form}` | `201 { ok: true }` — see [Forms](#forms) |
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
curl -H "x-mapled-key: $MAPLED_KEY" \
|
|
430
|
+
"https://api.mapled.io/v1/delivery/collections/articles/records?filter[featured]=true&filter[date][gte]=2026-01-01&sort=-date&limit=10&expand=author&fields=title,slug"
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
- Lists take `filter[field]=value` or `filter[field][op]=value` (`eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `in` — comma-separated, `contains`), `sort=field` / `sort=-field`, `limit` (1–100, default 100) and `offset`. Every read takes `expand` (relation keys, dotted for a second level), `fields` and `release=N` to read a published release by its number. A query the schema can't answer is a 400 `INVALID_QUERY` that says why.
|
|
434
|
+
- Only published content of collections with access **Public** is served; sensitive fields are never in an answer. Image and file values are asset ids — `https://api.mapled.io/files/{id}` is the file, with `?w=`, `h=`, `fit=`, `fmt=`, `q=` for a resized variant.
|
|
435
|
+
- Answers are per key — `Cache-Control: private` — with an `ETag`: send it back as `If-None-Match` and an unchanged answer is a 304. A read pinned with `release=N` never changes and may be cached for good. `GET /v1/delivery/release` is the cheap way to learn that something was published.
|
|
436
|
+
- Errors share one envelope: `{ error: { code, message, requestId } }`. A key gets 1,200 requests a minute; past that, 429 `RATE_LIMITED` with `retry-after`.
|
|
437
|
+
- The publish webhook is a `POST` with a JSON body `{ event, project, release, at, id }` and the headers `x-mapled-event` (`release.published`, `project.restored` or `test`), `x-mapled-event-id` (the same on every retry — use it to deduplicate), `x-mapled-attempt` and `x-mapled-signature`: `sha256=` followed by the hex HMAC-SHA256 of the raw body with the signing secret. Compare in constant time, answer 2xx within 5 seconds. `verifySignature` from `@mapled/next/server` does the check anywhere Web Crypto exists.
|
|
438
|
+
- Live data: `GET` / `POST /v1/app/collections/{collection}/records` and `GET` / `PATCH` / `DELETE /v1/app/collections/{collection}/records/{id}` with the server key in `x-mapled-server-key`; writes send `{ "data": { … } }`.
|
|
439
|
+
|
|
440
|
+
Preview (drafts on the site) is built on Next.js draft mode and ships with this package only.
|
|
441
|
+
|
|
148
442
|
## API
|
|
149
443
|
|
|
150
444
|
- `createClient({ key, apiUrl?, release? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getRecordBySlug(collection, slug, opts?)`, `getSingle(key, opts?)`, `getRelease()`; `createClient<MapledSchema>(…)` with the generated `MapledSchema` types every read by its key (`MapledClient<S>`, `MapledAppClient<S>` name the typed clients)
|
|
151
445
|
- list `opts`: `filter` (a value for equality, or `{ eq, ne, lt, lte, gt, gte, in, contains }` — text takes eq/ne/in/contains, numbers and dates comparisons, booleans eq/ne, image and relation ids eq/ne/in; a one-to-many relation matches when it includes the id), `sort` (`"field"` / `"-field"`, not on relations or images), `limit` (≤100), `offset`
|
|
152
446
|
- every read: `expand` (relation keys, dotted for a second level), `fields` (data keys to keep), `release` (pin to a published release), `revalidate`, `tags`; a query the schema can't answer fails with a 400 `INVALID_QUERY` message, and errors carry the API's `code` on `MapledError`
|
|
447
|
+
- `createAppClient({ serverKey, apiUrl? })` → `list(collection, opts?)`, `get(collection, id)`, `create(collection, data)`, `update(collection, id, data)`, `remove(collection, id)`
|
|
448
|
+
- `assetUrl(id, { width?, height?, fit?, format?, quality?, apiUrl? })` — URL of an image or file value
|
|
153
449
|
- `createRevalidateHandler({ secret, tags? })` — App Router `POST` handler
|
|
450
|
+
- `createPreviewHandler({ apiUrl?, appUrl? })`, `createExitPreviewHandler()` — App Router `GET` handlers of the preview routes
|
|
154
451
|
- `verifySignature(secret, body, signature)` — if you'd rather build your own handler
|
|
155
452
|
|
|
156
453
|
The delivery key only reads published, public content — safe to use anywhere.
|
package/dist/index.d.ts
CHANGED
|
@@ -51,6 +51,10 @@ export type ListResult<T = Record<string, unknown>> = {
|
|
|
51
51
|
records: MapledRecord<T>[];
|
|
52
52
|
total: number;
|
|
53
53
|
release: number;
|
|
54
|
+
/** Renamed fields whose previous keys this answer still serves, by
|
|
55
|
+
collection: `{ products: { price: "amount" } }`. Absent when nothing
|
|
56
|
+
was renamed. Outside production the SDK warns about each once. */
|
|
57
|
+
aliases?: Record<string, Record<string, string>>;
|
|
54
58
|
};
|
|
55
59
|
export type ImageOptions = {
|
|
56
60
|
/** Snapped up to the nearest preset (64 … 2560); never upscaled past the source. */
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
/** Mapled delivery client for Next.js sites.
|
|
2
2
|
Reads published content only — pair it with the revalidation webhook
|
|
3
3
|
handler from "@mapled/next/server" for instant updates on publish. */
|
|
4
|
+
/** Renamed fields (previous keys): Mapled keeps serving a renamed field
|
|
5
|
+
under its old key — and accepting it in filters, sorts and writes —
|
|
6
|
+
until the alias is removed in Field settings, and names such keys in
|
|
7
|
+
its answers. Outside production each is reported once per process, so
|
|
8
|
+
the site moves to the new key before the alias goes. */
|
|
9
|
+
const KEY_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
10
|
+
const reported = new Set();
|
|
11
|
+
function reportAliases(body) {
|
|
12
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV === "production")
|
|
13
|
+
return;
|
|
14
|
+
const aliases = body?.aliases;
|
|
15
|
+
if (!aliases || typeof aliases !== "object")
|
|
16
|
+
return;
|
|
17
|
+
for (const [collection, keys] of Object.entries(aliases)) {
|
|
18
|
+
if (!keys || typeof keys !== "object" || !KEY_RE.test(collection))
|
|
19
|
+
continue;
|
|
20
|
+
for (const [previous, current] of Object.entries(keys)) {
|
|
21
|
+
// keys are the project's content: only what looks like a key is printed
|
|
22
|
+
if (typeof current !== "string" || !KEY_RE.test(previous) || !KEY_RE.test(current))
|
|
23
|
+
continue;
|
|
24
|
+
const id = `${collection}.${previous}`;
|
|
25
|
+
if (reported.has(id))
|
|
26
|
+
continue;
|
|
27
|
+
reported.add(id);
|
|
28
|
+
console.warn(`[mapled] “${previous}” in “${collection}” was renamed to “${current}”. The old key keeps working until its alias is removed in Mapled — use “${current}” instead.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
4
32
|
/** URL of an image or file value (an asset id) — the original, or a
|
|
5
33
|
resized variant made on first request and cached from then on. */
|
|
6
34
|
export function assetUrl(id, opts = {}) {
|
|
@@ -95,7 +123,9 @@ export function createClient(config) {
|
|
|
95
123
|
const res = await fetch(`${base}${path}${qs ? `?${qs}` : ""}`, init);
|
|
96
124
|
if (!res.ok)
|
|
97
125
|
throw await failure(res);
|
|
98
|
-
|
|
126
|
+
const body = (await res.json());
|
|
127
|
+
reportAliases(body);
|
|
128
|
+
return { status: res.status, body };
|
|
99
129
|
}
|
|
100
130
|
/** expand / fields on every read. */
|
|
101
131
|
const readParams = (search, opts) => {
|
|
@@ -198,7 +228,9 @@ export function createAppClient(config) {
|
|
|
198
228
|
}
|
|
199
229
|
throw new MapledError(res.status, message);
|
|
200
230
|
}
|
|
201
|
-
|
|
231
|
+
const body = (await res.json());
|
|
232
|
+
reportAliases(body);
|
|
233
|
+
return body;
|
|
202
234
|
}
|
|
203
235
|
const collectionPath = (c) => `/v1/app/collections/${encodeURIComponent(c)}/records`;
|
|
204
236
|
const client = {
|
package/package.json
CHANGED