@mapled/next 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,20 +25,44 @@ const mapled = createClient({ key: process.env.MAPLED_KEY! });
25
25
 
26
26
  // A collection, with filters, sort and pagination
27
27
  const { records } = await mapled.getRecords("articles", {
28
- filter: { featured: true },
28
+ filter: { featured: true, date: { gte: "2026-01-01" }, title: { contains: "launch" } },
29
29
  sort: "-date",
30
30
  limit: 10,
31
31
  });
32
32
 
33
- // One record
33
+ // One record — by id or by its slug field
34
34
  const post = await mapled.getRecord("articles", id);
35
+ const about = await mapled.getRecordBySlug("pages", "about");
35
36
 
36
37
  // A single (e.g. the homepage)
37
38
  const home = await mapled.getSingle("homepage");
39
+
40
+ // Linked records come along under `expanded` (two levels at most)
41
+ const { records: posts } = await mapled.getRecords("articles", {
42
+ expand: ["author", "related.author"],
43
+ fields: ["title", "slug"],
44
+ });
45
+ posts[0].expanded?.author; // { id, data: { name: "Ada" } } or null
38
46
  ```
39
47
 
40
48
  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
49
 
50
+ ## Rich text
51
+
52
+ `rich_text` values are Markdown (headings, lists, links, bold/italic, images as `![alt](url)`). Render them with a Markdown component such as `react-markdown` — never inject them as raw HTML.
53
+
54
+ ## Images
55
+
56
+ Image fields hold asset ids. `assetUrl` turns one into a URL — the original, or a resized variant that Mapled makes on the first request and keeps:
57
+
58
+ ```ts
59
+ import { assetUrl } from "@mapled/next";
60
+
61
+ <img src={assetUrl(post.data.cover, { width: 640 })} alt={post.data.title} />
62
+ // width/height snap to presets (64 … 2560) and never upscale; fit: inside | cover | contain;
63
+ // format: webp (default) | avif | jpeg | png; quality 30 … 100
64
+ ```
65
+
42
66
  ## Refresh on publish
43
67
 
44
68
  Mount the webhook handler:
@@ -72,10 +96,32 @@ export const GET = createExitPreviewHandler();
72
96
 
73
97
  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.
74
98
 
99
+ ## Live data
100
+
101
+ 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.
102
+
103
+ ```bash
104
+ MAPLED_SERVER_KEY=msk_live_…
105
+ ```
106
+
107
+ ```ts
108
+ import { createAppClient } from "@mapled/next";
109
+
110
+ const db = createAppClient({ serverKey: process.env.MAPLED_SERVER_KEY! });
111
+
112
+ await db.create("orders", { customer: "Ada Lovelace", total: 120 });
113
+ const { records } = await db.list("orders", { filter: { status: "new" }, sort: "-total" });
114
+ await db.update("orders", id, { ...data, status: "shipped" });
115
+ await db.remove("orders", id);
116
+ ```
117
+
118
+ 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.
119
+
75
120
  ## API
76
121
 
77
- - `createClient({ key, apiUrl? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getSingle(key, opts?)`
78
- - `opts`: `filter` (equality on top-level fields), `sort` (`"field"` / `"-field"`), `limit` (≤100), `offset`, `revalidate`, `tags`
122
+ - `createClient({ key, apiUrl? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getRecordBySlug(collection, slug, opts?)`, `getSingle(key, opts?)`
123
+ - 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`
124
+ - every read: `expand` (relation keys, dotted for a second level), `fields` (data keys to keep), `revalidate`, `tags`; a query the schema can't answer fails with a 400 `INVALID_QUERY` message
79
125
  - `createRevalidateHandler({ secret, tags? })` — App Router `POST` handler
80
126
  - `verifySignature(secret, body, signature)` — if you'd rather build your own handler
81
127
 
package/dist/index.d.ts CHANGED
@@ -4,30 +4,81 @@
4
4
  export type MapledRecord<T = Record<string, unknown>> = {
5
5
  id: string;
6
6
  data: T;
7
+ /** Linked records asked for with `expand`: one record (or null) for a
8
+ one-to-one field, an array for one-to-many. */
9
+ expanded?: Record<string, MapledRecord | MapledRecord[] | null>;
7
10
  };
8
- export type ListOptions = {
9
- /** Equality filters on top-level fields, e.g. { slug: "about" }. */
10
- filter?: Record<string, string | number | boolean>;
11
+ /** A filter value: plain equality, or operators by field type —
12
+ text: eq/ne/in/contains; number and date: eq/ne/lt/lte/gt/gte/in;
13
+ boolean: eq/ne; image and relation ids: eq/ne/in. */
14
+ export type FilterValue = string | number | boolean | {
15
+ eq?: string | number | boolean;
16
+ ne?: string | number | boolean;
17
+ lt?: string | number;
18
+ lte?: string | number;
19
+ gt?: string | number;
20
+ gte?: string | number;
21
+ in?: (string | number)[];
22
+ contains?: string;
23
+ };
24
+ export type ReadOptions = {
25
+ /** Relation fields to attach under `expanded`, up to two levels:
26
+ ["author", "related.author"]. */
27
+ expand?: string[];
28
+ /** Only these data keys come back. */
29
+ fields?: string[];
30
+ /** ISR window in seconds (default 3600 — the webhook keeps you fresh). */
31
+ revalidate?: number | false;
32
+ /** Extra cache tags on top of "mapled" and "mapled:<collection>". */
33
+ tags?: string[];
34
+ };
35
+ export type ListOptions = ReadOptions & {
36
+ /** Filters on top-level fields, ANDed: { slug: "about", price: { gte: 10 } }. */
37
+ filter?: Record<string, FilterValue>;
11
38
  /** Field key, or "-field" for descending. */
12
39
  sort?: string;
13
40
  /** 1..100, default 100. */
14
41
  limit?: number;
15
42
  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
43
  };
21
44
  export type ListResult<T = Record<string, unknown>> = {
22
45
  records: MapledRecord<T>[];
23
46
  total: number;
24
47
  release: number;
25
48
  };
49
+ export type ImageOptions = {
50
+ /** Snapped up to the nearest preset (64 … 2560); never upscaled past the source. */
51
+ width?: number;
52
+ height?: number;
53
+ /** inside (default) keeps the whole image within the box; cover fills it; contain pads it. */
54
+ fit?: "cover" | "contain" | "inside";
55
+ /** webp (default), avif, jpeg or png. */
56
+ format?: "webp" | "avif" | "jpeg" | "png";
57
+ /** 30 … 100, in steps of 10 (default 80). */
58
+ quality?: number;
59
+ apiUrl?: string;
60
+ };
61
+ /** URL of an image or file value (an asset id) — the original, or a
62
+ resized variant made on first request and cached from then on. */
63
+ export declare function assetUrl(id: string, opts?: ImageOptions): string;
26
64
  export declare class MapledError extends Error {
27
65
  status: number;
28
66
  constructor(status: number, message: string);
29
67
  }
30
68
  export type MapledClient = ReturnType<typeof createClient>;
69
+ export type MapledAppClient = ReturnType<typeof createAppClient>;
70
+ export type AppRecord<T = Record<string, unknown>> = {
71
+ id: string;
72
+ data: T;
73
+ createdAt: string;
74
+ updatedAt: string;
75
+ };
76
+ export type AppListOptions = {
77
+ filter?: Record<string, string | number | boolean>;
78
+ sort?: string;
79
+ limit?: number;
80
+ offset?: number;
81
+ };
31
82
  export declare function createClient(config: {
32
83
  key: string;
33
84
  apiUrl?: string;
@@ -35,7 +86,25 @@ export declare function createClient(config: {
35
86
  /** Published records of a collection, with filters/sort/pagination. */
36
87
  getRecords<T = Record<string, unknown>>(collection: string, opts?: ListOptions): Promise<ListResult<T>>;
37
88
  /** 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>;
89
+ getRecord<T = Record<string, unknown>>(collection: string, id: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
90
+ /** One published record by the collection's slug field, or null. */
91
+ getRecordBySlug<T = Record<string, unknown>>(collection: string, slug: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
39
92
  /** 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>;
93
+ getSingle<T = Record<string, unknown>>(key: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
94
+ };
95
+ /** Live data (operational collections): the site's backend reads and
96
+ writes records that take effect immediately — no publish involved.
97
+ Server-side only: the server key must never reach the browser. */
98
+ export declare function createAppClient(config: {
99
+ serverKey: string;
100
+ apiUrl?: string;
101
+ }): {
102
+ list<T = Record<string, unknown>>(collection: string, opts?: AppListOptions): Promise<{
103
+ records: AppRecord<T>[];
104
+ total: number;
105
+ }>;
106
+ get<T = Record<string, unknown>>(collection: string, id: string): Promise<AppRecord<T> | null>;
107
+ create<T = Record<string, unknown>>(collection: string, data: T): Promise<AppRecord<T>>;
108
+ update<T = Record<string, unknown>>(collection: string, id: string, data: T): Promise<AppRecord<T>>;
109
+ remove(collection: string, id: string): Promise<void>;
41
110
  };
package/dist/index.js CHANGED
@@ -1,6 +1,24 @@
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
+ /** URL of an image or file value (an asset id) — the original, or a
5
+ resized variant made on first request and cached from then on. */
6
+ export function assetUrl(id, opts = {}) {
7
+ const base = (opts.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
8
+ const search = new URLSearchParams();
9
+ if (opts.width)
10
+ search.set("w", String(opts.width));
11
+ if (opts.height)
12
+ search.set("h", String(opts.height));
13
+ if (opts.fit)
14
+ search.set("fit", opts.fit);
15
+ if (opts.format)
16
+ search.set("fmt", opts.format);
17
+ if (opts.quality)
18
+ search.set("q", String(opts.quality));
19
+ const qs = search.toString();
20
+ return `${base}/files/${encodeURIComponent(id)}${qs ? `?${qs}` : ""}`;
21
+ }
4
22
  export class MapledError extends Error {
5
23
  status;
6
24
  constructor(status, message) {
@@ -58,12 +76,40 @@ export function createClient(config) {
58
76
  }
59
77
  return { status: res.status, body: (await res.json()) };
60
78
  }
79
+ /** expand / fields on every read. */
80
+ const readParams = (search, opts) => {
81
+ if (opts.expand?.length)
82
+ search.set("expand", opts.expand.join(","));
83
+ if (opts.fields?.length)
84
+ search.set("fields", opts.fields.join(","));
85
+ return search;
86
+ };
87
+ const one = async (path, opts, tag) => {
88
+ try {
89
+ const { body } = await call(path, readParams(new URLSearchParams(), opts), opts, [tag]);
90
+ return body.record;
91
+ }
92
+ catch (err) {
93
+ if (err instanceof MapledError && err.status === 404)
94
+ return null;
95
+ throw err;
96
+ }
97
+ };
61
98
  return {
62
99
  /** Published records of a collection, with filters/sort/pagination. */
63
100
  async getRecords(collection, opts = {}) {
64
101
  const search = new URLSearchParams();
65
102
  for (const [k, v] of Object.entries(opts.filter ?? {})) {
66
- search.set(`filter[${k}]`, String(v));
103
+ if (v !== null && typeof v === "object") {
104
+ for (const [op, ov] of Object.entries(v)) {
105
+ if (ov === undefined)
106
+ continue;
107
+ search.set(`filter[${k}][${op}]`, Array.isArray(ov) ? ov.join(",") : String(ov));
108
+ }
109
+ }
110
+ else {
111
+ search.set(`filter[${k}]`, String(v));
112
+ }
67
113
  }
68
114
  if (opts.sort)
69
115
  search.set("sort", opts.sort);
@@ -71,25 +117,74 @@ export function createClient(config) {
71
117
  search.set("limit", String(opts.limit));
72
118
  if (opts.offset !== undefined)
73
119
  search.set("offset", String(opts.offset));
120
+ readParams(search, opts);
74
121
  const { body } = await call(`/v1/delivery/collections/${encodeURIComponent(collection)}/records`, search, opts, [`mapled:${collection}`]);
75
122
  return body;
76
123
  },
77
124
  /** One published record by id, or null when it isn't in the release. */
78
- async getRecord(collection, id, opts = {}) {
125
+ getRecord(collection, id, opts = {}) {
126
+ return one(`/v1/delivery/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`, opts, `mapled:${collection}`);
127
+ },
128
+ /** One published record by the collection's slug field, or null. */
129
+ getRecordBySlug(collection, slug, opts = {}) {
130
+ return one(`/v1/delivery/collections/${encodeURIComponent(collection)}/records/by-slug/${encodeURIComponent(slug)}`, opts, `mapled:${collection}`);
131
+ },
132
+ /** The record of a single (e.g. "homepage"), or null before publish. */
133
+ getSingle(key, opts = {}) {
134
+ return one(`/v1/delivery/singles/${encodeURIComponent(key)}`, opts, `mapled:${key}`);
135
+ },
136
+ };
137
+ }
138
+ /** Live data (operational collections): the site's backend reads and
139
+ writes records that take effect immediately — no publish involved.
140
+ Server-side only: the server key must never reach the browser. */
141
+ export function createAppClient(config) {
142
+ const base = (config.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
143
+ if (!config.serverKey)
144
+ throw new Error("Mapled: a server key is required.");
145
+ async function call(method, path, search, data) {
146
+ const qs = search?.toString();
147
+ const res = await fetch(`${base}${path}${qs ? `?${qs}` : ""}`, {
148
+ method,
149
+ headers: {
150
+ "x-mapled-server-key": config.serverKey,
151
+ ...(data !== undefined ? { "content-type": "application/json" } : {}),
152
+ },
153
+ ...(data !== undefined ? { body: JSON.stringify({ data }) } : {}),
154
+ cache: "no-store",
155
+ });
156
+ if (!res.ok) {
157
+ let message = `Mapled request failed (${res.status}).`;
79
158
  try {
80
- const { body } = await call(`/v1/delivery/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`, new URLSearchParams(), opts, [`mapled:${collection}`]);
81
- return body.record;
159
+ const parsed = (await res.json());
160
+ if (parsed.error?.message)
161
+ message = parsed.error.message;
82
162
  }
83
- catch (err) {
84
- if (err instanceof MapledError && err.status === 404)
85
- return null;
86
- throw err;
163
+ catch {
164
+ /* non-JSON error body */
165
+ }
166
+ throw new MapledError(res.status, message);
167
+ }
168
+ return (await res.json());
169
+ }
170
+ const collectionPath = (c) => `/v1/app/collections/${encodeURIComponent(c)}/records`;
171
+ return {
172
+ async list(collection, opts = {}) {
173
+ const search = new URLSearchParams();
174
+ for (const [k, v] of Object.entries(opts.filter ?? {})) {
175
+ search.set(`filter[${k}]`, String(v));
87
176
  }
177
+ if (opts.sort)
178
+ search.set("sort", opts.sort);
179
+ if (opts.limit !== undefined)
180
+ search.set("limit", String(opts.limit));
181
+ if (opts.offset !== undefined)
182
+ search.set("offset", String(opts.offset));
183
+ return call("GET", collectionPath(collection), search);
88
184
  },
89
- /** The record of a single (e.g. "homepage"), or null before publish. */
90
- async getSingle(key, opts = {}) {
185
+ async get(collection, id) {
91
186
  try {
92
- const { body } = await call(`/v1/delivery/singles/${encodeURIComponent(key)}`, new URLSearchParams(), opts, [`mapled:${key}`]);
187
+ const body = await call("GET", `${collectionPath(collection)}/${encodeURIComponent(id)}`);
93
188
  return body.record;
94
189
  }
95
190
  catch (err) {
@@ -98,5 +193,16 @@ export function createClient(config) {
98
193
  throw err;
99
194
  }
100
195
  },
196
+ async create(collection, data) {
197
+ const body = await call("POST", collectionPath(collection), undefined, data);
198
+ return body.record;
199
+ },
200
+ async update(collection, id, data) {
201
+ const body = await call("PATCH", `${collectionPath(collection)}/${encodeURIComponent(id)}`, undefined, data);
202
+ return body.record;
203
+ },
204
+ async remove(collection, id) {
205
+ await call("DELETE", `${collectionPath(collection)}/${encodeURIComponent(id)}`);
206
+ },
101
207
  };
102
208
  }
package/dist/server.d.ts CHANGED
@@ -15,6 +15,7 @@ export declare function verifySignature(secret: string, body: string, signature:
15
15
  turns on Next draft mode, and redirects into the site. */
16
16
  export declare function createPreviewHandler(options?: {
17
17
  apiUrl?: string;
18
+ appUrl?: string;
18
19
  }): (req: Request) => Promise<Response>;
19
20
  /** GET handler to leave preview (mount at /api/mapled/preview/exit). */
20
21
  export declare function createExitPreviewHandler(): (req: Request) => Promise<Response>;
package/dist/server.js CHANGED
@@ -37,6 +37,7 @@ function safeNext(raw) {
37
37
  turns on Next draft mode, and redirects into the site. */
38
38
  export function createPreviewHandler(options = {}) {
39
39
  const base = (options.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
40
+ const app = (options.appUrl ?? "https://app.mapled.io").replace(/\/+$/, "");
40
41
  return async function GET(req) {
41
42
  const url = new URL(req.url);
42
43
  const token = url.searchParams.get("token");
@@ -48,9 +49,11 @@ export function createPreviewHandler(options = {}) {
48
49
  body: JSON.stringify({ token }),
49
50
  });
50
51
  if (!res.ok) {
51
- return new Response("This preview link has expired. Open it from Mapled again.", {
52
- status: 401,
53
- });
52
+ // Spent or expired link (S-35): back to Mapled to start a new preview.
53
+ const payload = (await res.json().catch(() => null));
54
+ const projectId = payload?.error?.projectId;
55
+ const target = `${app}/preview/expired${projectId ? `?project=${encodeURIComponent(projectId)}` : ""}`;
56
+ return new Response(null, { status: 307, headers: { location: target } });
54
57
  }
55
58
  const { draftToken, expiresAt } = (await res.json());
56
59
  const { draftMode, cookies } = await import("next/headers");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/next",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Read published Mapled content in a Next.js site — delivery client, ISR tags, and a revalidation webhook handler.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://mapled.io",