@mapled/next 0.3.0 → 0.5.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 +41 -4
- package/dist/index.d.ts +67 -10
- package/dist/index.js +110 -36
- package/dist/server.d.ts +1 -0
- package/dist/server.js +6 -3
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -25,20 +25,56 @@ 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
|
+
### Pin a release
|
|
51
|
+
|
|
52
|
+
A page that makes several reads can pin them all to one release, so a publish landing mid-render can't mix two versions:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const live = await mapled.getRelease(); // { release: 12, publishedAt } — null before the first publish
|
|
56
|
+
const { records } = await mapled.getRecords("articles", { release: live!.release });
|
|
57
|
+
const about = await mapled.getRecordBySlug("pages", "about", { release: live!.release });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`createClient({ key, release: 12 })` pins every read — a way to hold a site on a known-good release without publishing anything. A release never changes, so pinned reads are cached until the next publish (`revalidate: false` unless you pass your own); a release that doesn't exist fails with a 404 `RELEASE_NOT_FOUND` rather than reading as a missing record. In preview (draft mode) the pin is dropped: drafts are unversioned.
|
|
61
|
+
|
|
62
|
+
## Rich text
|
|
63
|
+
|
|
64
|
+
`rich_text` values are Markdown (headings, lists, links, bold/italic, images as ``). Render them with a Markdown component such as `react-markdown` — never inject them as raw HTML.
|
|
65
|
+
|
|
66
|
+
## Images
|
|
67
|
+
|
|
68
|
+
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:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { assetUrl } from "@mapled/next";
|
|
72
|
+
|
|
73
|
+
<img src={assetUrl(post.data.cover, { width: 640 })} alt={post.data.title} />
|
|
74
|
+
// width/height snap to presets (64 … 2560) and never upscale; fit: inside | cover | contain;
|
|
75
|
+
// format: webp (default) | avif | jpeg | png; quality 30 … 100
|
|
76
|
+
```
|
|
77
|
+
|
|
42
78
|
## Refresh on publish
|
|
43
79
|
|
|
44
80
|
Mount the webhook handler:
|
|
@@ -95,8 +131,9 @@ The server key reaches only operational collections with access class `public`/`
|
|
|
95
131
|
|
|
96
132
|
## API
|
|
97
133
|
|
|
98
|
-
- `createClient({ key, apiUrl? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getSingle(key, opts?)`
|
|
99
|
-
- `opts`: `filter` (equality
|
|
134
|
+
- `createClient({ key, apiUrl?, release? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getRecordBySlug(collection, slug, opts?)`, `getSingle(key, opts?)`, `getRelease()`
|
|
135
|
+
- 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`
|
|
136
|
+
- 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`
|
|
100
137
|
- `createRevalidateHandler({ secret, tags? })` — App Router `POST` handler
|
|
101
138
|
- `verifySignature(secret, body, signature)` — if you'd rather build your own handler
|
|
102
139
|
|
package/dist/index.d.ts
CHANGED
|
@@ -4,28 +4,74 @@
|
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
+
/** Read this release instead of the latest one: pin a render to the
|
|
35
|
+
number its first read returned so a publish landing mid-render can't
|
|
36
|
+
mix two versions, or hold a site on a known-good release. A release
|
|
37
|
+
never changes, so pinned reads stay cached until the next publish
|
|
38
|
+
(`revalidate: false` unless you say otherwise). */
|
|
39
|
+
release?: number;
|
|
40
|
+
};
|
|
41
|
+
export type ListOptions = ReadOptions & {
|
|
42
|
+
/** Filters on top-level fields, ANDed: { slug: "about", price: { gte: 10 } }. */
|
|
43
|
+
filter?: Record<string, FilterValue>;
|
|
11
44
|
/** Field key, or "-field" for descending. */
|
|
12
45
|
sort?: string;
|
|
13
46
|
/** 1..100, default 100. */
|
|
14
47
|
limit?: number;
|
|
15
48
|
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
49
|
};
|
|
21
50
|
export type ListResult<T = Record<string, unknown>> = {
|
|
22
51
|
records: MapledRecord<T>[];
|
|
23
52
|
total: number;
|
|
24
53
|
release: number;
|
|
25
54
|
};
|
|
55
|
+
export type ImageOptions = {
|
|
56
|
+
/** Snapped up to the nearest preset (64 … 2560); never upscaled past the source. */
|
|
57
|
+
width?: number;
|
|
58
|
+
height?: number;
|
|
59
|
+
/** inside (default) keeps the whole image within the box; cover fills it; contain pads it. */
|
|
60
|
+
fit?: "cover" | "contain" | "inside";
|
|
61
|
+
/** webp (default), avif, jpeg or png. */
|
|
62
|
+
format?: "webp" | "avif" | "jpeg" | "png";
|
|
63
|
+
/** 30 … 100, in steps of 10 (default 80). */
|
|
64
|
+
quality?: number;
|
|
65
|
+
apiUrl?: string;
|
|
66
|
+
};
|
|
67
|
+
/** URL of an image or file value (an asset id) — the original, or a
|
|
68
|
+
resized variant made on first request and cached from then on. */
|
|
69
|
+
export declare function assetUrl(id: string, opts?: ImageOptions): string;
|
|
26
70
|
export declare class MapledError extends Error {
|
|
27
71
|
status: number;
|
|
28
|
-
|
|
72
|
+
/** The API's error code — "NOT_FOUND", "INVALID_QUERY", "RELEASE_NOT_FOUND", … */
|
|
73
|
+
code: string | undefined;
|
|
74
|
+
constructor(status: number, message: string, code?: string);
|
|
29
75
|
}
|
|
30
76
|
export type MapledClient = ReturnType<typeof createClient>;
|
|
31
77
|
export type MapledAppClient = ReturnType<typeof createAppClient>;
|
|
@@ -44,13 +90,24 @@ export type AppListOptions = {
|
|
|
44
90
|
export declare function createClient(config: {
|
|
45
91
|
key: string;
|
|
46
92
|
apiUrl?: string;
|
|
93
|
+
release?: number;
|
|
47
94
|
}): {
|
|
48
95
|
/** Published records of a collection, with filters/sort/pagination. */
|
|
49
96
|
getRecords<T = Record<string, unknown>>(collection: string, opts?: ListOptions): Promise<ListResult<T>>;
|
|
50
97
|
/** One published record by id, or null when it isn't in the release. */
|
|
51
|
-
getRecord<T = Record<string, unknown>>(collection: string, id: string, opts?:
|
|
98
|
+
getRecord<T = Record<string, unknown>>(collection: string, id: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
|
|
99
|
+
/** One published record by the collection's slug field, or null. */
|
|
100
|
+
getRecordBySlug<T = Record<string, unknown>>(collection: string, slug: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
|
|
52
101
|
/** The record of a single (e.g. "homepage"), or null before publish. */
|
|
53
|
-
getSingle<T = Record<string, unknown>>(key: string, opts?:
|
|
102
|
+
getSingle<T = Record<string, unknown>>(key: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
|
|
103
|
+
/** The release the site is reading right now — the latest published
|
|
104
|
+
one, with the moment it went live — or null before the first
|
|
105
|
+
publish. Never cached: it's the one short-lived pointer of the
|
|
106
|
+
delivery API; pass its number as `release` to pin a render. */
|
|
107
|
+
getRelease(): Promise<{
|
|
108
|
+
release: number;
|
|
109
|
+
publishedAt: string;
|
|
110
|
+
} | null>;
|
|
54
111
|
};
|
|
55
112
|
/** Live data (operational collections): the site's backend reads and
|
|
56
113
|
writes records that take effect immediately — no publish involved.
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
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
|
+
/** The API's error code — "NOT_FOUND", "INVALID_QUERY", "RELEASE_NOT_FOUND", … */
|
|
25
|
+
code;
|
|
26
|
+
constructor(status, message, code) {
|
|
7
27
|
super(message);
|
|
8
28
|
this.name = "MapledError";
|
|
9
29
|
this.status = status;
|
|
30
|
+
this.code = code;
|
|
10
31
|
}
|
|
11
32
|
}
|
|
33
|
+
const isRelease = (n) => Number.isInteger(n) && n >= 1;
|
|
34
|
+
const badRelease = () => new Error("Mapled: release must be a positive whole number.");
|
|
12
35
|
/** The draft session cookie set by the preview route. When Next draft
|
|
13
36
|
mode is on, every read switches to live drafts, uncached. Outside a
|
|
14
37
|
Next request context this quietly resolves to null. */
|
|
@@ -28,9 +51,33 @@ export function createClient(config) {
|
|
|
28
51
|
const base = (config.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
|
|
29
52
|
if (!config.key)
|
|
30
53
|
throw new Error("Mapled: a delivery key is required.");
|
|
54
|
+
if (config.release !== undefined && !isRelease(config.release))
|
|
55
|
+
throw badRelease();
|
|
56
|
+
/** The API's error envelope as a MapledError. */
|
|
57
|
+
async function failure(res) {
|
|
58
|
+
let message = `Mapled request failed (${res.status}).`;
|
|
59
|
+
let code;
|
|
60
|
+
try {
|
|
61
|
+
const parsed = (await res.json());
|
|
62
|
+
if (parsed.error?.message)
|
|
63
|
+
message = parsed.error.message;
|
|
64
|
+
if (typeof parsed.error?.code === "string")
|
|
65
|
+
code = parsed.error.code;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* non-JSON error body */
|
|
69
|
+
}
|
|
70
|
+
return new MapledError(res.status, message, code);
|
|
71
|
+
}
|
|
31
72
|
async function call(path, search, opts, cacheTags) {
|
|
32
|
-
const qs = search.toString();
|
|
33
73
|
const draft = await draftSession();
|
|
74
|
+
const release = opts.release ?? config.release;
|
|
75
|
+
if (release !== undefined && !isRelease(release))
|
|
76
|
+
throw badRelease();
|
|
77
|
+
// drafts are unversioned: a pin only shapes published reads
|
|
78
|
+
if (release !== undefined && !draft)
|
|
79
|
+
search.set("release", String(release));
|
|
80
|
+
const qs = search.toString();
|
|
34
81
|
const init = draft
|
|
35
82
|
? {
|
|
36
83
|
headers: { "x-mapled-key": config.key, "x-mapled-draft": draft },
|
|
@@ -39,31 +86,53 @@ export function createClient(config) {
|
|
|
39
86
|
: {
|
|
40
87
|
headers: { "x-mapled-key": config.key },
|
|
41
88
|
next: {
|
|
42
|
-
|
|
89
|
+
// a release never changes: a pinned read stays cached until
|
|
90
|
+
// the publish webhook revalidates the tags
|
|
91
|
+
revalidate: opts.revalidate ?? (release !== undefined ? false : 3600),
|
|
43
92
|
tags: ["mapled", ...cacheTags, ...(opts.tags ?? [])],
|
|
44
93
|
},
|
|
45
94
|
};
|
|
46
95
|
const res = await fetch(`${base}${path}${qs ? `?${qs}` : ""}`, init);
|
|
47
|
-
if (!res.ok)
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
const parsed = (await res.json());
|
|
51
|
-
if (parsed.error?.message)
|
|
52
|
-
message = parsed.error.message;
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
/* non-JSON error body */
|
|
56
|
-
}
|
|
57
|
-
throw new MapledError(res.status, message);
|
|
58
|
-
}
|
|
96
|
+
if (!res.ok)
|
|
97
|
+
throw await failure(res);
|
|
59
98
|
return { status: res.status, body: (await res.json()) };
|
|
60
99
|
}
|
|
100
|
+
/** expand / fields on every read. */
|
|
101
|
+
const readParams = (search, opts) => {
|
|
102
|
+
if (opts.expand?.length)
|
|
103
|
+
search.set("expand", opts.expand.join(","));
|
|
104
|
+
if (opts.fields?.length)
|
|
105
|
+
search.set("fields", opts.fields.join(","));
|
|
106
|
+
return search;
|
|
107
|
+
};
|
|
108
|
+
const one = async (path, opts, tag) => {
|
|
109
|
+
try {
|
|
110
|
+
const { body } = await call(path, readParams(new URLSearchParams(), opts), opts, [tag]);
|
|
111
|
+
return body.record;
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
// a missing record is null; a missing release is a configuration error
|
|
115
|
+
if (err instanceof MapledError && err.status === 404 && err.code !== "RELEASE_NOT_FOUND") {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
61
121
|
return {
|
|
62
122
|
/** Published records of a collection, with filters/sort/pagination. */
|
|
63
123
|
async getRecords(collection, opts = {}) {
|
|
64
124
|
const search = new URLSearchParams();
|
|
65
125
|
for (const [k, v] of Object.entries(opts.filter ?? {})) {
|
|
66
|
-
|
|
126
|
+
if (v !== null && typeof v === "object") {
|
|
127
|
+
for (const [op, ov] of Object.entries(v)) {
|
|
128
|
+
if (ov === undefined)
|
|
129
|
+
continue;
|
|
130
|
+
search.set(`filter[${k}][${op}]`, Array.isArray(ov) ? ov.join(",") : String(ov));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
search.set(`filter[${k}]`, String(v));
|
|
135
|
+
}
|
|
67
136
|
}
|
|
68
137
|
if (opts.sort)
|
|
69
138
|
search.set("sort", opts.sort);
|
|
@@ -71,32 +140,37 @@ export function createClient(config) {
|
|
|
71
140
|
search.set("limit", String(opts.limit));
|
|
72
141
|
if (opts.offset !== undefined)
|
|
73
142
|
search.set("offset", String(opts.offset));
|
|
143
|
+
readParams(search, opts);
|
|
74
144
|
const { body } = await call(`/v1/delivery/collections/${encodeURIComponent(collection)}/records`, search, opts, [`mapled:${collection}`]);
|
|
75
145
|
return body;
|
|
76
146
|
},
|
|
77
147
|
/** One published record by id, or null when it isn't in the release. */
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (err instanceof MapledError && err.status === 404)
|
|
85
|
-
return null;
|
|
86
|
-
throw err;
|
|
87
|
-
}
|
|
148
|
+
getRecord(collection, id, opts = {}) {
|
|
149
|
+
return one(`/v1/delivery/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`, opts, `mapled:${collection}`);
|
|
150
|
+
},
|
|
151
|
+
/** One published record by the collection's slug field, or null. */
|
|
152
|
+
getRecordBySlug(collection, slug, opts = {}) {
|
|
153
|
+
return one(`/v1/delivery/collections/${encodeURIComponent(collection)}/records/by-slug/${encodeURIComponent(slug)}`, opts, `mapled:${collection}`);
|
|
88
154
|
},
|
|
89
155
|
/** The record of a single (e.g. "homepage"), or null before publish. */
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
156
|
+
getSingle(key, opts = {}) {
|
|
157
|
+
return one(`/v1/delivery/singles/${encodeURIComponent(key)}`, opts, `mapled:${key}`);
|
|
158
|
+
},
|
|
159
|
+
/** The release the site is reading right now — the latest published
|
|
160
|
+
one, with the moment it went live — or null before the first
|
|
161
|
+
publish. Never cached: it's the one short-lived pointer of the
|
|
162
|
+
delivery API; pass its number as `release` to pin a render. */
|
|
163
|
+
async getRelease() {
|
|
164
|
+
const res = await fetch(`${base}/v1/delivery/release`, {
|
|
165
|
+
headers: { "x-mapled-key": config.key },
|
|
166
|
+
cache: "no-store",
|
|
167
|
+
});
|
|
168
|
+
if (!res.ok)
|
|
169
|
+
throw await failure(res);
|
|
170
|
+
const body = (await res.json());
|
|
171
|
+
if (body.release === null || body.publishedAt === null)
|
|
172
|
+
return null;
|
|
173
|
+
return { release: body.release, publishedAt: body.publishedAt };
|
|
100
174
|
},
|
|
101
175
|
};
|
|
102
176
|
}
|
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
|
-
|
|
52
|
-
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mapled/next",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Read published Mapled content in a Next.js site
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
7
7
|
"keywords": [
|
|
@@ -47,6 +47,6 @@
|
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"typescript": "^5.7.2",
|
|
50
|
-
"vitest": "^
|
|
50
|
+
"vitest": "^4.1.11"
|
|
51
51
|
}
|
|
52
52
|
}
|