@mapled/next 0.7.0 → 0.8.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 +351 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/live.d.ts +12 -0
- package/dist/live.js +32 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +69 -0
- package/dist/watch.d.ts +23 -0
- package/dist/watch.js +96 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -1,21 +1,220 @@
|
|
|
1
1
|
# @mapled/next
|
|
2
2
|
|
|
3
|
-
Read published [Mapled](https://mapled.io) content in a Next.js site — a delivery client with ISR cache tags,
|
|
3
|
+
Read published [Mapled](https://mapled.io) content in a Next.js site — a delivery client with ISR cache tags, a revalidation webhook handler so publishes show up instantly, and live updates for the tabs that are already open.
|
|
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) • [Live updates](#live-updates) • [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
|
+
Optional: pages that are already open can follow a publish too, without a reload — a route and one component, see [Live updates](#live-updates).
|
|
157
|
+
|
|
158
|
+
### 7. Preview drafts
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
// app/api/mapled/preview/route.ts
|
|
162
|
+
import { createPreviewHandler } from "@mapled/next/server";
|
|
163
|
+
|
|
164
|
+
export const GET = createPreviewHandler();
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
// app/api/mapled/preview/exit/route.ts
|
|
169
|
+
import { createExitPreviewHandler } from "@mapled/next/server";
|
|
170
|
+
|
|
171
|
+
export const GET = createExitPreviewHandler();
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
### 8. Forms
|
|
177
|
+
|
|
178
|
+
A contact or signup form posts straight to Mapled; submissions land in **Forms**, with e-mail notifications and spam protection. Create the form once:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
npx @mapled/cli forms create "Contact"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
and post to it from the site — see [Forms](#forms) for the component.
|
|
185
|
+
|
|
186
|
+
### 9. Live data
|
|
187
|
+
|
|
188
|
+
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).
|
|
189
|
+
|
|
190
|
+
### 10. Record the integration
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npx @mapled/cli scan --write
|
|
194
|
+
npx @mapled/cli bindings push
|
|
195
|
+
npx @mapled/cli md pull
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`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.
|
|
199
|
+
|
|
200
|
+
### 11. Check everything
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
npx @mapled/cli doctor
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
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:
|
|
207
|
+
|
|
208
|
+
- the site reads content from Mapled (“Site reads content — Last read …”);
|
|
209
|
+
- a publish refreshes it (“Publish webhook — Delivered …”);
|
|
210
|
+
- **Preview** in Mapled opens the site with drafts (“Preview on the site — Responds at …”);
|
|
211
|
+
- bindings are pushed and healthy, and the integration is in sync (“Bindings”, “Integration”);
|
|
212
|
+
- no env file or secret is tracked by git (“Secrets in git”);
|
|
213
|
+
- every field has a help text (**Structure** → **Collections**);
|
|
214
|
+
- the site builds.
|
|
215
|
+
|
|
216
|
+
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.
|
|
217
|
+
|
|
19
218
|
## Read published content
|
|
20
219
|
|
|
21
220
|
```ts
|
|
@@ -114,7 +313,67 @@ export const POST = createRevalidateHandler({
|
|
|
114
313
|
});
|
|
115
314
|
```
|
|
116
315
|
|
|
117
|
-
Then in Mapled (
|
|
316
|
+
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.
|
|
317
|
+
|
|
318
|
+
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.
|
|
319
|
+
|
|
320
|
+
## Live updates
|
|
321
|
+
|
|
322
|
+
The webhook refreshes the site's cache; a page someone already has open still shows what it rendered. Two files make open tabs follow Publish as well. A route that says which release the site serves:
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
// app/api/mapled/release/route.ts
|
|
326
|
+
import { createReleaseHandler } from "@mapled/next/server";
|
|
327
|
+
|
|
328
|
+
export const GET = createReleaseHandler({ key: process.env.MAPLED_KEY! });
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
and one component in the root layout:
|
|
332
|
+
|
|
333
|
+
```tsx
|
|
334
|
+
// app/layout.tsx
|
|
335
|
+
import type { ReactNode } from "react";
|
|
336
|
+
import { MapledLive } from "@mapled/next/live";
|
|
337
|
+
|
|
338
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
339
|
+
return (
|
|
340
|
+
<html lang="en">
|
|
341
|
+
<body>
|
|
342
|
+
{children}
|
|
343
|
+
<MapledLive />
|
|
344
|
+
</body>
|
|
345
|
+
</html>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
`<MapledLive />` renders nothing. While the tab is visible it asks the route every 10 seconds (`interval`, in seconds), and when a publish lands it calls `router.refresh()`: Server Components re-render with the new release in place — scroll position and client state stay. A hidden tab doesn't ask; it checks the moment it is shown again. To offer the update instead of applying it under the reader, pass `onPublish`:
|
|
351
|
+
|
|
352
|
+
```tsx
|
|
353
|
+
"use client";
|
|
354
|
+
|
|
355
|
+
import { useRouter } from "next/navigation";
|
|
356
|
+
import { useState } from "react";
|
|
357
|
+
import { MapledLive } from "@mapled/next/live";
|
|
358
|
+
|
|
359
|
+
export function UpdateNotice() {
|
|
360
|
+
const router = useRouter();
|
|
361
|
+
const [fresh, setFresh] = useState(false);
|
|
362
|
+
return (
|
|
363
|
+
<>
|
|
364
|
+
<MapledLive onPublish={() => setFresh(true)} />
|
|
365
|
+
{fresh ? <button onClick={() => { router.refresh(); setFresh(false); }}>New content — refresh</button> : null}
|
|
366
|
+
</>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
- It is polling, and it is cheap: tabs ask **your** route, never Mapled. The route reuses one answer for `ttl` seconds (default 5) — in the instance and, through `s-maxage`, at the CDN in front of it — and a check that finds nothing new is a 304. However many tabs are open, Mapled hears from the site a few times a minute, and the key's rate limit is left for reads.
|
|
372
|
+
- The route reads the release under the same `mapled` cache tag as the content, so it moves when the webhook refreshes the cache and not before — a tab that refreshes on it never renders the old release again. Live updates therefore need the webhook from [Refresh on publish](#refresh-on-publish); without it both change only when their ISR window (`revalidate`, 3600 by default) runs out.
|
|
373
|
+
- The route answers `{ release, publishedAt }` — the number of publishes and the time of the last one, nothing else — and may be cached by anyone. While Mapled can't be reached it keeps answering the last release it knew.
|
|
374
|
+
- Expect a publish to reach an open tab in about ten seconds with the defaults. `<MapledLive interval={3} />` and `createReleaseHandler({ key, ttl: 1 })` make it quicker at the price of more requests to your site.
|
|
375
|
+
|
|
376
|
+
Outside React, `watchRelease({ onPublish })` from `@mapled/next` is the same watch as a plain function — it returns the function that stops it.
|
|
118
377
|
|
|
119
378
|
## Preview drafts
|
|
120
379
|
|
|
@@ -134,6 +393,64 @@ export const GET = createExitPreviewHandler();
|
|
|
134
393
|
|
|
135
394
|
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
395
|
|
|
396
|
+
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.
|
|
397
|
+
|
|
398
|
+
## Forms
|
|
399
|
+
|
|
400
|
+
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.
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
// app/contact/ContactForm.tsx
|
|
404
|
+
"use client";
|
|
405
|
+
|
|
406
|
+
import { useState, type FormEvent } from "react";
|
|
407
|
+
|
|
408
|
+
const CONSENT = "I agree to be contacted about my request.";
|
|
409
|
+
|
|
410
|
+
export function ContactForm() {
|
|
411
|
+
const [state, setState] = useState<"idle" | "sending" | "sent" | "failed">("idle");
|
|
412
|
+
|
|
413
|
+
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
414
|
+
event.preventDefault();
|
|
415
|
+
setState("sending");
|
|
416
|
+
const form = new FormData(event.currentTarget);
|
|
417
|
+
const res = await fetch("https://api.mapled.io/v1/delivery/forms/contact", {
|
|
418
|
+
method: "POST",
|
|
419
|
+
headers: { "content-type": "application/json", "x-mapled-key": process.env.NEXT_PUBLIC_MAPLED_KEY! },
|
|
420
|
+
body: JSON.stringify({
|
|
421
|
+
name: form.get("name"),
|
|
422
|
+
email: form.get("email"),
|
|
423
|
+
message: form.get("message"),
|
|
424
|
+
_gotcha: form.get("_gotcha"), // the honeypot: people leave it empty
|
|
425
|
+
_consent: form.get("consent") ? CONSENT : undefined,
|
|
426
|
+
_page: window.location.pathname,
|
|
427
|
+
}),
|
|
428
|
+
});
|
|
429
|
+
setState(res.ok ? "sent" : "failed");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (state === "sent") return <p>Thank you — we'll be in touch.</p>;
|
|
433
|
+
return (
|
|
434
|
+
<form onSubmit={submit}>
|
|
435
|
+
<input name="name" required />
|
|
436
|
+
<input name="email" type="email" required />
|
|
437
|
+
<textarea name="message" required />
|
|
438
|
+
<input name="_gotcha" tabIndex={-1} autoComplete="off" aria-hidden="true" style={{ display: "none" }} />
|
|
439
|
+
<label>
|
|
440
|
+
<input name="consent" type="checkbox" required /> {CONSENT}
|
|
441
|
+
</label>
|
|
442
|
+
<button disabled={state === "sending"}>Send</button>
|
|
443
|
+
{state === "failed" ? <p>That didn't go through. Try again in a minute.</p> : null}
|
|
444
|
+
</form>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
- 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.
|
|
450
|
+
- 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).
|
|
451
|
+
- `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.
|
|
452
|
+
- `npx @mapled/cli forms list` shows the project's forms and their URLs.
|
|
453
|
+
|
|
137
454
|
## Live data
|
|
138
455
|
|
|
139
456
|
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 +472,43 @@ await db.remove("orders", id);
|
|
|
155
472
|
|
|
156
473
|
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
474
|
|
|
475
|
+
## Without Next.js: the HTTP API
|
|
476
|
+
|
|
477
|
+
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`.
|
|
478
|
+
|
|
479
|
+
| Request | Answer |
|
|
480
|
+
| --- | --- |
|
|
481
|
+
| `GET /v1/delivery/collections/{collection}/records` | `{ records: [{ id, data, expanded? }], total, release }` |
|
|
482
|
+
| `GET /v1/delivery/collections/{collection}/records/{id}` | `{ record, release }` — 404 when it isn't in the release |
|
|
483
|
+
| `GET /v1/delivery/collections/{collection}/records/by-slug/{slug}` | `{ record, release }` |
|
|
484
|
+
| `GET /v1/delivery/singles/{single}` | `{ record, release }` — `record` is `null` while the single is empty |
|
|
485
|
+
| `GET /v1/delivery/release` | `{ release, publishedAt }` — the release being served now; `null`s before the first publish |
|
|
486
|
+
| `POST /v1/delivery/forms/{form}` | `201 { ok: true }` — see [Forms](#forms) |
|
|
487
|
+
|
|
488
|
+
```bash
|
|
489
|
+
curl -H "x-mapled-key: $MAPLED_KEY" \
|
|
490
|
+
"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"
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
- 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.
|
|
494
|
+
- 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.
|
|
495
|
+
- 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: relay it from a route of your own server, and `watchRelease({ endpoint, onPublish })` from `@mapled/next` — no framework in it — tells open tabs ([Live updates](#live-updates)).
|
|
496
|
+
- Errors share one envelope: `{ error: { code, message, requestId } }`. A key gets 1,200 requests a minute; past that, 429 `RATE_LIMITED` with `retry-after`.
|
|
497
|
+
- 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.
|
|
498
|
+
- 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": { … } }`.
|
|
499
|
+
|
|
500
|
+
Preview (drafts on the site) is built on Next.js draft mode and ships with this package only.
|
|
501
|
+
|
|
158
502
|
## API
|
|
159
503
|
|
|
160
504
|
- `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
505
|
- 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
506
|
- 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`
|
|
507
|
+
- `createAppClient({ serverKey, apiUrl? })` → `list(collection, opts?)`, `get(collection, id)`, `create(collection, data)`, `update(collection, id, data)`, `remove(collection, id)`
|
|
508
|
+
- `assetUrl(id, { width?, height?, fit?, format?, quality?, apiUrl? })` — URL of an image or file value
|
|
163
509
|
- `createRevalidateHandler({ secret, tags? })` — App Router `POST` handler
|
|
510
|
+
- `createPreviewHandler({ apiUrl?, appUrl? })`, `createExitPreviewHandler()` — App Router `GET` handlers of the preview routes
|
|
511
|
+
- `createReleaseHandler({ key, apiUrl?, ttl?, revalidate? })` — App Router `GET` handler of the release route; `<MapledLive endpoint? interval? onPublish? />` from `@mapled/next/live` and `watchRelease({ endpoint?, interval?, onPublish })` ask it
|
|
164
512
|
- `verifySignature(secret, body, signature)` — if you'd rather build your own handler
|
|
165
513
|
|
|
166
514
|
The delivery key only reads published, public content — safe to use anywhere.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
|
+
export { watchRelease, type Release, type WatchOptions } from "./watch.js";
|
|
4
5
|
export type MapledRecord<T = Record<string, unknown>> = {
|
|
5
6
|
id: string;
|
|
6
7
|
data: T;
|
|
@@ -158,4 +159,3 @@ export declare function createAppClient<S extends ContentSchema = ContentSchema>
|
|
|
158
159
|
serverKey: string;
|
|
159
160
|
apiUrl?: string;
|
|
160
161
|
}): MapledAppClient<S>;
|
|
161
|
-
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
|
+
export { watchRelease } from "./watch.js";
|
|
4
5
|
/** Renamed fields (previous keys): Mapled keeps serving a renamed field
|
|
5
6
|
under its old key — and accepting it in filters, sorts and writes —
|
|
6
7
|
until the alias is removed in Field settings, and names such keys in
|
package/dist/live.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Release } from "./watch.js";
|
|
2
|
+
export type { Release } from "./watch.js";
|
|
3
|
+
export type MapledLiveProps = {
|
|
4
|
+
/** Where the release route is mounted (default "/api/mapled/release"). */
|
|
5
|
+
endpoint?: string;
|
|
6
|
+
/** Seconds between checks while the tab is visible (default 10, at least 2). */
|
|
7
|
+
interval?: number;
|
|
8
|
+
/** Runs instead of the refresh — to offer "New content — refresh"
|
|
9
|
+
rather than change the page under the reader. */
|
|
10
|
+
onPublish?: (next: Release, previous: number | null) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare function MapledLive({ endpoint, interval, onPublish }: MapledLiveProps): null;
|
package/dist/live.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/** Open tabs follow Publish. Render it once, in the root layout:
|
|
3
|
+
|
|
4
|
+
app/layout.tsx:
|
|
5
|
+
import { MapledLive } from "@mapled/next/live";
|
|
6
|
+
<body>{children}<MapledLive /></body>
|
|
7
|
+
|
|
8
|
+
It asks the release route (`createReleaseHandler` from
|
|
9
|
+
"@mapled/next/server", mounted at /api/mapled/release) every few
|
|
10
|
+
seconds and, when a publish lands, refreshes the page's server
|
|
11
|
+
components in place — scroll position and client state stay. */
|
|
12
|
+
import { useRouter } from "next/navigation";
|
|
13
|
+
import { useEffect, useRef } from "react";
|
|
14
|
+
import { watchRelease } from "./watch.js";
|
|
15
|
+
export function MapledLive({ endpoint, interval, onPublish }) {
|
|
16
|
+
const router = useRouter();
|
|
17
|
+
const custom = useRef(onPublish);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
custom.current = onPublish;
|
|
20
|
+
}, [onPublish]);
|
|
21
|
+
useEffect(() => watchRelease({
|
|
22
|
+
endpoint,
|
|
23
|
+
interval,
|
|
24
|
+
onPublish: (next, previous) => {
|
|
25
|
+
if (custom.current)
|
|
26
|
+
custom.current(next, previous);
|
|
27
|
+
else
|
|
28
|
+
router.refresh();
|
|
29
|
+
},
|
|
30
|
+
}), [endpoint, interval, router]);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -25,3 +25,19 @@ export declare function createRevalidateHandler(options: {
|
|
|
25
25
|
client fetch. */
|
|
26
26
|
tags?: string[];
|
|
27
27
|
}): (req: Request) => Promise<Response>;
|
|
28
|
+
/** GET handler for the release route (mount at /api/mapled/release):
|
|
29
|
+
the release this site serves right now, for `<MapledLive />` from
|
|
30
|
+
"@mapled/next/live" to ask about. The pointer is read under the
|
|
31
|
+
"mapled" tag like the content is, so it moves when the publish webhook
|
|
32
|
+
refreshes the cache and not before — a tab that refreshes on it
|
|
33
|
+
renders the new release, never the old one again. One answer is reused
|
|
34
|
+
for `ttl` seconds, in this instance and at the CDN in front of it:
|
|
35
|
+
however many tabs ask, Mapled hears from the site a few times a minute. */
|
|
36
|
+
export declare function createReleaseHandler(options: {
|
|
37
|
+
key: string;
|
|
38
|
+
apiUrl?: string;
|
|
39
|
+
/** Seconds one answer is reused (default 5, at least 1). */
|
|
40
|
+
ttl?: number;
|
|
41
|
+
/** ISR window of the pointer, the same as a read's (default 3600 — the webhook keeps it fresh). */
|
|
42
|
+
revalidate?: number | false;
|
|
43
|
+
}): (req: Request) => Promise<Response>;
|
package/dist/server.js
CHANGED
|
@@ -99,3 +99,72 @@ export function createRevalidateHandler(options) {
|
|
|
99
99
|
return Response.json({ revalidated: true });
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
+
/** GET handler for the release route (mount at /api/mapled/release):
|
|
103
|
+
the release this site serves right now, for `<MapledLive />` from
|
|
104
|
+
"@mapled/next/live" to ask about. The pointer is read under the
|
|
105
|
+
"mapled" tag like the content is, so it moves when the publish webhook
|
|
106
|
+
refreshes the cache and not before — a tab that refreshes on it
|
|
107
|
+
renders the new release, never the old one again. One answer is reused
|
|
108
|
+
for `ttl` seconds, in this instance and at the CDN in front of it:
|
|
109
|
+
however many tabs ask, Mapled hears from the site a few times a minute. */
|
|
110
|
+
export function createReleaseHandler(options) {
|
|
111
|
+
if (!options.key)
|
|
112
|
+
throw new Error("Mapled: a delivery key is required.");
|
|
113
|
+
const base = (options.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
|
|
114
|
+
const ttlMs = Math.max(1, options.ttl ?? 5) * 1000;
|
|
115
|
+
let held = null;
|
|
116
|
+
/** when Mapled was last asked — an answer or a failure, either waits a ttl */
|
|
117
|
+
let askedAt = 0;
|
|
118
|
+
let loading = null;
|
|
119
|
+
let complained = false;
|
|
120
|
+
async function load() {
|
|
121
|
+
try {
|
|
122
|
+
const init = {
|
|
123
|
+
headers: { "x-mapled-key": options.key },
|
|
124
|
+
next: { revalidate: options.revalidate ?? 3600, tags: ["mapled"] },
|
|
125
|
+
};
|
|
126
|
+
const res = await fetch(`${base}/v1/delivery/release`, init);
|
|
127
|
+
if (!res.ok)
|
|
128
|
+
throw new Error(`Mapled answered ${res.status}`);
|
|
129
|
+
const body = (await res.json());
|
|
130
|
+
const release = body?.release ?? null;
|
|
131
|
+
const publishedAt = body?.publishedAt ?? null;
|
|
132
|
+
const published = Number.isInteger(release) && release >= 1 && typeof publishedAt === "string";
|
|
133
|
+
if (!published && release !== null)
|
|
134
|
+
throw new Error("Mapled's answer wasn't a release");
|
|
135
|
+
held = published ? { release, publishedAt } : { release: null, publishedAt: null };
|
|
136
|
+
complained = false;
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
// the last answer stays good while Mapled can't be reached
|
|
140
|
+
if (!complained) {
|
|
141
|
+
complained = true;
|
|
142
|
+
console.error(`[mapled] The release route can't read the live release: ${err instanceof Error ? err.message : "request failed"}.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
askedAt = Date.now();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return async function GET(req) {
|
|
150
|
+
// read first: a handler that looks at its request is never prerendered
|
|
151
|
+
const asked = req.headers.get("if-none-match");
|
|
152
|
+
if (Date.now() - askedAt >= ttlMs) {
|
|
153
|
+
loading ??= load().finally(() => {
|
|
154
|
+
loading = null;
|
|
155
|
+
});
|
|
156
|
+
await loading;
|
|
157
|
+
}
|
|
158
|
+
if (!held) {
|
|
159
|
+
return Response.json({ error: "The live release isn't available right now." }, { status: 502, headers: { "cache-control": "no-store" } });
|
|
160
|
+
}
|
|
161
|
+
// the CDN's copy lives no longer than this instance's
|
|
162
|
+
const left = Math.max(1, Math.ceil((ttlMs - (Date.now() - askedAt)) / 1000));
|
|
163
|
+
const etag = `W/"r${held.release ?? 0}"`;
|
|
164
|
+
const headers = { "cache-control": `public, max-age=0, s-maxage=${left}`, etag };
|
|
165
|
+
if (asked && asked.split(",").some((t) => t.trim().replace(/^W\//, "") === etag.slice(2))) {
|
|
166
|
+
return new Response(null, { status: 304, headers });
|
|
167
|
+
}
|
|
168
|
+
return Response.json(held, { headers });
|
|
169
|
+
};
|
|
170
|
+
}
|
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Publish notifications for open tabs: the page asks its own server
|
|
2
|
+
which release is live (the route `createReleaseHandler` from
|
|
3
|
+
"@mapled/next/server" serves) and hears about it when that moves.
|
|
4
|
+
Framework-free on purpose — `<MapledLive />` from "@mapled/next/live"
|
|
5
|
+
is this plus `router.refresh()`. */
|
|
6
|
+
export type Release = {
|
|
7
|
+
release: number;
|
|
8
|
+
publishedAt: string;
|
|
9
|
+
};
|
|
10
|
+
export type WatchOptions = {
|
|
11
|
+
/** Where the release route is mounted (default "/api/mapled/release"). */
|
|
12
|
+
endpoint?: string;
|
|
13
|
+
/** Seconds between checks while the tab is visible (default 10, at least 2). */
|
|
14
|
+
interval?: number;
|
|
15
|
+
/** A publish landed: `next` is live now; `previous` is the release the
|
|
16
|
+
watch knew before — null when nothing was published yet. */
|
|
17
|
+
onPublish: (next: Release, previous: number | null) => void;
|
|
18
|
+
};
|
|
19
|
+
/** Starts watching; returns the function that stops it. The first answer
|
|
20
|
+
is where the watch starts from; `onPublish` runs once per release that
|
|
21
|
+
comes after it. A hidden tab doesn't ask — it checks the moment it is
|
|
22
|
+
shown again — and a route that fails is asked less and less often. */
|
|
23
|
+
export declare function watchRelease(options: WatchOptions): () => void;
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Publish notifications for open tabs: the page asks its own server
|
|
2
|
+
which release is live (the route `createReleaseHandler` from
|
|
3
|
+
"@mapled/next/server" serves) and hears about it when that moves.
|
|
4
|
+
Framework-free on purpose — `<MapledLive />` from "@mapled/next/live"
|
|
5
|
+
is this plus `router.refresh()`. */
|
|
6
|
+
const DEFAULT_ENDPOINT = "/api/mapled/release";
|
|
7
|
+
const MAX_BACKOFF_MS = 5 * 60_000;
|
|
8
|
+
/** The route's answer — null before the first publish — or undefined
|
|
9
|
+
when it isn't one. */
|
|
10
|
+
function pointer(body) {
|
|
11
|
+
if (!body || typeof body !== "object")
|
|
12
|
+
return undefined;
|
|
13
|
+
const { release, publishedAt } = body;
|
|
14
|
+
if (release === null)
|
|
15
|
+
return null;
|
|
16
|
+
if (!Number.isInteger(release) || release < 1 || typeof publishedAt !== "string")
|
|
17
|
+
return undefined;
|
|
18
|
+
return { release: release, publishedAt };
|
|
19
|
+
}
|
|
20
|
+
/** Starts watching; returns the function that stops it. The first answer
|
|
21
|
+
is where the watch starts from; `onPublish` runs once per release that
|
|
22
|
+
comes after it. A hidden tab doesn't ask — it checks the moment it is
|
|
23
|
+
shown again — and a route that fails is asked less and less often. */
|
|
24
|
+
export function watchRelease(options) {
|
|
25
|
+
const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
|
|
26
|
+
const everyMs = Math.max(2, options.interval ?? 10) * 1000;
|
|
27
|
+
const doc = typeof document === "undefined" ? null : document;
|
|
28
|
+
/** undefined until the first answer; null while nothing is published */
|
|
29
|
+
let seen;
|
|
30
|
+
let failures = 0;
|
|
31
|
+
let stopped = false;
|
|
32
|
+
let timer;
|
|
33
|
+
let inFlight = null;
|
|
34
|
+
const visible = () => !doc || doc.visibilityState !== "hidden";
|
|
35
|
+
const schedule = () => {
|
|
36
|
+
if (stopped || !visible())
|
|
37
|
+
return;
|
|
38
|
+
timer = setTimeout(check, Math.min(everyMs * 2 ** failures, MAX_BACKOFF_MS));
|
|
39
|
+
};
|
|
40
|
+
async function check() {
|
|
41
|
+
if (stopped || inFlight)
|
|
42
|
+
return;
|
|
43
|
+
const request = new AbortController();
|
|
44
|
+
inFlight = request;
|
|
45
|
+
let news = null;
|
|
46
|
+
try {
|
|
47
|
+
// no-cache: the browser revalidates by ETag, so a quiet check is a 304
|
|
48
|
+
const res = await fetch(endpoint, { cache: "no-cache", headers: { accept: "application/json" }, signal: request.signal });
|
|
49
|
+
const now = res.ok ? pointer(await res.json()) : undefined;
|
|
50
|
+
if (stopped)
|
|
51
|
+
return;
|
|
52
|
+
if (now === undefined) {
|
|
53
|
+
failures += 1;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
failures = 0;
|
|
57
|
+
if (seen === undefined) {
|
|
58
|
+
seen = now ? now.release : null;
|
|
59
|
+
}
|
|
60
|
+
else if (now && now.release > (seen ?? 0)) {
|
|
61
|
+
// releases only grow (a rollback is a new one): an answer from a
|
|
62
|
+
// server that hasn't caught up yet never looks like news
|
|
63
|
+
news = { next: now, previous: seen };
|
|
64
|
+
seen = now.release;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
if (stopped)
|
|
70
|
+
return;
|
|
71
|
+
failures += 1;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
if (inFlight === request)
|
|
75
|
+
inFlight = null;
|
|
76
|
+
}
|
|
77
|
+
schedule();
|
|
78
|
+
// after the next check is set: a callback that throws doesn't end the watch
|
|
79
|
+
if (news)
|
|
80
|
+
options.onPublish(news.next, news.previous);
|
|
81
|
+
}
|
|
82
|
+
const onVisibility = () => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
if (visible())
|
|
85
|
+
void check();
|
|
86
|
+
};
|
|
87
|
+
doc?.addEventListener("visibilitychange", onVisibility);
|
|
88
|
+
if (visible())
|
|
89
|
+
void check();
|
|
90
|
+
return () => {
|
|
91
|
+
stopped = true;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
inFlight?.abort();
|
|
94
|
+
doc?.removeEventListener("visibilitychange", onVisibility);
|
|
95
|
+
};
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mapled/next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"./server": {
|
|
28
28
|
"types": "./dist/server.d.ts",
|
|
29
29
|
"default": "./dist/server.js"
|
|
30
|
+
},
|
|
31
|
+
"./live": {
|
|
32
|
+
"types": "./dist/live.d.ts",
|
|
33
|
+
"default": "./dist/live.js"
|
|
30
34
|
}
|
|
31
35
|
},
|
|
32
36
|
"files": [
|