@mapled/next 0.7.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.
Files changed (2) hide show
  1. package/README.md +289 -2
  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
@@ -114,7 +311,9 @@ export const POST = createRevalidateHandler({
114
311
  });
115
312
  ```
116
313
 
117
- Then in Mapled (project → 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.
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.
118
317
 
119
318
  ## Preview drafts
120
319
 
@@ -134,6 +333,64 @@ export const GET = createExitPreviewHandler();
134
333
 
135
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.
136
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
+
137
394
  ## Live data
138
395
 
139
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.
@@ -155,12 +412,42 @@ await db.remove("orders", id);
155
412
 
156
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.
157
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
+
158
442
  ## API
159
443
 
160
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)
161
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`
162
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
163
449
  - `createRevalidateHandler({ secret, tags? })` — App Router `POST` handler
450
+ - `createPreviewHandler({ apiUrl?, appUrl? })`, `createExitPreviewHandler()` — App Router `GET` handlers of the preview routes
164
451
  - `verifySignature(secret, body, signature)` — if you'd rather build your own handler
165
452
 
166
453
  The delivery key only reads published, public content — safe to use anywhere.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/next",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Read published Mapled content in a Next.js site \u2014 delivery client, ISR tags, and a revalidation webhook handler.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://mapled.io",