@mapled/next 0.1.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 +64 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +81 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +45 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @mapled/next
|
|
2
|
+
|
|
3
|
+
Read published [Mapled](https://mapled.io) content in a Next.js site — a delivery client with ISR cache tags, plus a revalidation webhook handler so publishes show up instantly.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @mapled/next
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Grab the delivery key in Mapled: project → Integrations → **Your site**, and put it in your site's env:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
MAPLED_KEY=mk_live_…
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Read published content
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { createClient } from "@mapled/next";
|
|
23
|
+
|
|
24
|
+
const mapled = createClient({ key: process.env.MAPLED_KEY! });
|
|
25
|
+
|
|
26
|
+
// A collection, with filters, sort and pagination
|
|
27
|
+
const { records } = await mapled.getRecords("articles", {
|
|
28
|
+
filter: { featured: true },
|
|
29
|
+
sort: "-date",
|
|
30
|
+
limit: 10,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// One record
|
|
34
|
+
const post = await mapled.getRecord("articles", id);
|
|
35
|
+
|
|
36
|
+
// A single (e.g. the homepage)
|
|
37
|
+
const home = await mapled.getSingle("homepage");
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Every call reads the latest **published release** — drafts never leak. Fetches are tagged `mapled` and `mapled:<collection>` and default to `revalidate: 3600`; the webhook below refreshes them the moment someone publishes.
|
|
41
|
+
|
|
42
|
+
## Refresh on publish
|
|
43
|
+
|
|
44
|
+
Mount the webhook handler:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// app/api/mapled/revalidate/route.ts
|
|
48
|
+
import { createRevalidateHandler } from "@mapled/next/server";
|
|
49
|
+
|
|
50
|
+
export const POST = createRevalidateHandler({
|
|
51
|
+
secret: process.env.MAPLED_WEBHOOK_SECRET!,
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
- `createClient({ key, apiUrl? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getSingle(key, opts?)`
|
|
60
|
+
- `opts`: `filter` (equality on top-level fields), `sort` (`"field"` / `"-field"`), `limit` (≤100), `offset`, `revalidate`, `tags`
|
|
61
|
+
- `createRevalidateHandler({ secret, tags? })` — App Router `POST` handler
|
|
62
|
+
- `verifySignature(secret, body, signature)` — if you'd rather build your own handler
|
|
63
|
+
|
|
64
|
+
The delivery key only reads published, public content — safe to use anywhere.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Mapled delivery client for Next.js sites.
|
|
2
|
+
Reads published content only — pair it with the revalidation webhook
|
|
3
|
+
handler from "@mapled/next/server" for instant updates on publish. */
|
|
4
|
+
export type MapledRecord<T = Record<string, unknown>> = {
|
|
5
|
+
id: string;
|
|
6
|
+
data: T;
|
|
7
|
+
};
|
|
8
|
+
export type ListOptions = {
|
|
9
|
+
/** Equality filters on top-level fields, e.g. { slug: "about" }. */
|
|
10
|
+
filter?: Record<string, string | number | boolean>;
|
|
11
|
+
/** Field key, or "-field" for descending. */
|
|
12
|
+
sort?: string;
|
|
13
|
+
/** 1..100, default 100. */
|
|
14
|
+
limit?: number;
|
|
15
|
+
offset?: number;
|
|
16
|
+
/** ISR window in seconds (default 3600 — the webhook keeps you fresh). */
|
|
17
|
+
revalidate?: number | false;
|
|
18
|
+
/** Extra cache tags on top of "mapled" and "mapled:<collection>". */
|
|
19
|
+
tags?: string[];
|
|
20
|
+
};
|
|
21
|
+
export type ListResult<T = Record<string, unknown>> = {
|
|
22
|
+
records: MapledRecord<T>[];
|
|
23
|
+
total: number;
|
|
24
|
+
release: number;
|
|
25
|
+
};
|
|
26
|
+
export declare class MapledError extends Error {
|
|
27
|
+
status: number;
|
|
28
|
+
constructor(status: number, message: string);
|
|
29
|
+
}
|
|
30
|
+
export type MapledClient = ReturnType<typeof createClient>;
|
|
31
|
+
export declare function createClient(config: {
|
|
32
|
+
key: string;
|
|
33
|
+
apiUrl?: string;
|
|
34
|
+
}): {
|
|
35
|
+
/** Published records of a collection, with filters/sort/pagination. */
|
|
36
|
+
getRecords<T = Record<string, unknown>>(collection: string, opts?: ListOptions): Promise<ListResult<T>>;
|
|
37
|
+
/** One published record by id, or null when it isn't in the release. */
|
|
38
|
+
getRecord<T = Record<string, unknown>>(collection: string, id: string, opts?: Pick<ListOptions, "revalidate" | "tags">): Promise<MapledRecord<T> | null>;
|
|
39
|
+
/** The record of a single (e.g. "homepage"), or null before publish. */
|
|
40
|
+
getSingle<T = Record<string, unknown>>(key: string, opts?: Pick<ListOptions, "revalidate" | "tags">): Promise<MapledRecord<T> | null>;
|
|
41
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Mapled delivery client for Next.js sites.
|
|
2
|
+
Reads published content only — pair it with the revalidation webhook
|
|
3
|
+
handler from "@mapled/next/server" for instant updates on publish. */
|
|
4
|
+
export class MapledError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(status, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "MapledError";
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function createClient(config) {
|
|
13
|
+
const base = (config.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
|
|
14
|
+
if (!config.key)
|
|
15
|
+
throw new Error("Mapled: a delivery key is required.");
|
|
16
|
+
async function call(path, search, opts, cacheTags) {
|
|
17
|
+
const qs = search.toString();
|
|
18
|
+
const init = {
|
|
19
|
+
headers: { "x-mapled-key": config.key },
|
|
20
|
+
next: {
|
|
21
|
+
revalidate: opts.revalidate ?? 3600,
|
|
22
|
+
tags: ["mapled", ...cacheTags, ...(opts.tags ?? [])],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const res = await fetch(`${base}${path}${qs ? `?${qs}` : ""}`, init);
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
let message = `Mapled request failed (${res.status}).`;
|
|
28
|
+
try {
|
|
29
|
+
const parsed = (await res.json());
|
|
30
|
+
if (parsed.error?.message)
|
|
31
|
+
message = parsed.error.message;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* non-JSON error body */
|
|
35
|
+
}
|
|
36
|
+
throw new MapledError(res.status, message);
|
|
37
|
+
}
|
|
38
|
+
return { status: res.status, body: (await res.json()) };
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
/** Published records of a collection, with filters/sort/pagination. */
|
|
42
|
+
async getRecords(collection, opts = {}) {
|
|
43
|
+
const search = new URLSearchParams();
|
|
44
|
+
for (const [k, v] of Object.entries(opts.filter ?? {})) {
|
|
45
|
+
search.set(`filter[${k}]`, String(v));
|
|
46
|
+
}
|
|
47
|
+
if (opts.sort)
|
|
48
|
+
search.set("sort", opts.sort);
|
|
49
|
+
if (opts.limit !== undefined)
|
|
50
|
+
search.set("limit", String(opts.limit));
|
|
51
|
+
if (opts.offset !== undefined)
|
|
52
|
+
search.set("offset", String(opts.offset));
|
|
53
|
+
const { body } = await call(`/v1/delivery/collections/${encodeURIComponent(collection)}/records`, search, opts, [`mapled:${collection}`]);
|
|
54
|
+
return body;
|
|
55
|
+
},
|
|
56
|
+
/** One published record by id, or null when it isn't in the release. */
|
|
57
|
+
async getRecord(collection, id, opts = {}) {
|
|
58
|
+
try {
|
|
59
|
+
const { body } = await call(`/v1/delivery/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`, new URLSearchParams(), opts, [`mapled:${collection}`]);
|
|
60
|
+
return body.record;
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err instanceof MapledError && err.status === 404)
|
|
64
|
+
return null;
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
/** The record of a single (e.g. "homepage"), or null before publish. */
|
|
69
|
+
async getSingle(key, opts = {}) {
|
|
70
|
+
try {
|
|
71
|
+
const { body } = await call(`/v1/delivery/singles/${encodeURIComponent(key)}`, new URLSearchParams(), opts, [`mapled:${key}`]);
|
|
72
|
+
return body.record;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err instanceof MapledError && err.status === 404)
|
|
76
|
+
return null;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Revalidation webhook handler for Next.js App Router.
|
|
2
|
+
|
|
3
|
+
app/api/mapled/revalidate/route.ts:
|
|
4
|
+
import { createRevalidateHandler } from "@mapled/next/server";
|
|
5
|
+
export const POST = createRevalidateHandler({
|
|
6
|
+
secret: process.env.MAPLED_WEBHOOK_SECRET!,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
Point the project's webhook at /api/mapled/revalidate — every publish
|
|
10
|
+
then refreshes all "mapled"-tagged fetches instantly. */
|
|
11
|
+
/** Constant-time check of the "x-mapled-signature" header value. */
|
|
12
|
+
export declare function verifySignature(secret: string, body: string, signature: string): Promise<boolean>;
|
|
13
|
+
export declare function createRevalidateHandler(options: {
|
|
14
|
+
secret: string;
|
|
15
|
+
/** Cache tags to refresh — defaults to ["mapled"], which covers every
|
|
16
|
+
client fetch. */
|
|
17
|
+
tags?: string[];
|
|
18
|
+
}): (req: Request) => Promise<Response>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Revalidation webhook handler for Next.js App Router.
|
|
2
|
+
|
|
3
|
+
app/api/mapled/revalidate/route.ts:
|
|
4
|
+
import { createRevalidateHandler } from "@mapled/next/server";
|
|
5
|
+
export const POST = createRevalidateHandler({
|
|
6
|
+
secret: process.env.MAPLED_WEBHOOK_SECRET!,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
Point the project's webhook at /api/mapled/revalidate — every publish
|
|
10
|
+
then refreshes all "mapled"-tagged fetches instantly. */
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
async function hmacHex(secret, body) {
|
|
13
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
14
|
+
const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
|
15
|
+
return Array.from(new Uint8Array(mac))
|
|
16
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
17
|
+
.join("");
|
|
18
|
+
}
|
|
19
|
+
/** Constant-time check of the "x-mapled-signature" header value. */
|
|
20
|
+
export async function verifySignature(secret, body, signature) {
|
|
21
|
+
const expected = `sha256=${await hmacHex(secret, body)}`;
|
|
22
|
+
if (expected.length !== signature.length)
|
|
23
|
+
return false;
|
|
24
|
+
let diff = 0;
|
|
25
|
+
for (let i = 0; i < expected.length; i++) {
|
|
26
|
+
diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
|
|
27
|
+
}
|
|
28
|
+
return diff === 0;
|
|
29
|
+
}
|
|
30
|
+
export function createRevalidateHandler(options) {
|
|
31
|
+
if (!options.secret) {
|
|
32
|
+
throw new Error("Mapled: the webhook secret is required.");
|
|
33
|
+
}
|
|
34
|
+
return async function POST(req) {
|
|
35
|
+
const body = await req.text();
|
|
36
|
+
const signature = req.headers.get("x-mapled-signature");
|
|
37
|
+
if (!signature || !(await verifySignature(options.secret, body, signature))) {
|
|
38
|
+
return Response.json({ error: "Invalid signature." }, { status: 401 });
|
|
39
|
+
}
|
|
40
|
+
const { revalidateTag } = await import("next/cache");
|
|
41
|
+
for (const tag of options.tags ?? ["mapled"])
|
|
42
|
+
revalidateTag(tag);
|
|
43
|
+
return Response.json({ revalidated: true });
|
|
44
|
+
};
|
|
45
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mapled/next",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read published Mapled content in a Next.js site — delivery client, ISR tags, and a revalidation webhook handler.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://mapled.io",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"mapled",
|
|
9
|
+
"cms",
|
|
10
|
+
"headless-cms",
|
|
11
|
+
"nextjs",
|
|
12
|
+
"isr",
|
|
13
|
+
"revalidation"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./server": {
|
|
28
|
+
"types": "./dist/server.d.ts",
|
|
29
|
+
"default": "./dist/server.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.json",
|
|
37
|
+
"prepare": "npm run build",
|
|
38
|
+
"test": "vitest run"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"next": ">=14"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"next": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"typescript": "^5.7.2",
|
|
50
|
+
"vitest": "^3.0.5"
|
|
51
|
+
}
|
|
52
|
+
}
|