@mapled/next 0.4.0 → 0.6.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 +30 -2
- package/dist/index.d.ts +72 -25
- package/dist/index.js +56 -22
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -47,6 +47,34 @@ posts[0].expanded?.author; // { id, data: { name: "Ada" } } or null
|
|
|
47
47
|
|
|
48
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.
|
|
49
49
|
|
|
50
|
+
### Typed by your schema
|
|
51
|
+
|
|
52
|
+
`npx @mapled/cli types generate` writes `mapled-types.ts`: one interface per collection and single, and a `MapledSchema` that ties them together. Hand it to the client once and every read is typed by its key:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import type { MapledSchema } from "./mapled-types";
|
|
56
|
+
|
|
57
|
+
const mapled = createClient<MapledSchema>({ key: process.env.MAPLED_KEY! });
|
|
58
|
+
|
|
59
|
+
const { records } = await mapled.getRecords("articles"); // records[0].data is an Article
|
|
60
|
+
const home = await mapled.getSingle("homepage"); // Homepage | null
|
|
61
|
+
await mapled.getRecords("artcles"); // type error — no such collection
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Without a schema the client behaves as before: any key, `Record<string, unknown>` data, or the type you name per call (`getRecords<Article>("articles")`). `createAppClient<MapledSchema>` types live data the same way — `db.create("orders", data)` wants an `Order`. `mapled doctor` tells you when the generated file is behind the schema.
|
|
65
|
+
|
|
66
|
+
### Pin a release
|
|
67
|
+
|
|
68
|
+
A page that makes several reads can pin them all to one release, so a publish landing mid-render can't mix two versions:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const live = await mapled.getRelease(); // { release: 12, publishedAt } — null before the first publish
|
|
72
|
+
const { records } = await mapled.getRecords("articles", { release: live!.release });
|
|
73
|
+
const about = await mapled.getRecordBySlug("pages", "about", { release: live!.release });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`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.
|
|
77
|
+
|
|
50
78
|
## Rich text
|
|
51
79
|
|
|
52
80
|
`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.
|
|
@@ -119,9 +147,9 @@ The server key reaches only operational collections with access class `public`/`
|
|
|
119
147
|
|
|
120
148
|
## API
|
|
121
149
|
|
|
122
|
-
- `createClient({ key, apiUrl? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getRecordBySlug(collection, slug, opts?)`, `getSingle(key, opts?)`
|
|
150
|
+
- `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)
|
|
123
151
|
- 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
|
|
152
|
+
- 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`
|
|
125
153
|
- `createRevalidateHandler({ secret, tags? })` — App Router `POST` handler
|
|
126
154
|
- `verifySignature(secret, body, signature)` — if you'd rather build your own handler
|
|
127
155
|
|
package/dist/index.d.ts
CHANGED
|
@@ -31,6 +31,12 @@ export type ReadOptions = {
|
|
|
31
31
|
revalidate?: number | false;
|
|
32
32
|
/** Extra cache tags on top of "mapled" and "mapled:<collection>". */
|
|
33
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;
|
|
34
40
|
};
|
|
35
41
|
export type ListOptions = ReadOptions & {
|
|
36
42
|
/** Filters on top-level fields, ANDed: { slug: "about", price: { gte: 10 } }. */
|
|
@@ -63,10 +69,67 @@ export type ImageOptions = {
|
|
|
63
69
|
export declare function assetUrl(id: string, opts?: ImageOptions): string;
|
|
64
70
|
export declare class MapledError extends Error {
|
|
65
71
|
status: number;
|
|
66
|
-
|
|
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);
|
|
75
|
+
}
|
|
76
|
+
/** The shape of the `MapledSchema` that `mapled types generate` writes:
|
|
77
|
+
collections and singles by key, each the type of a record's data.
|
|
78
|
+
Pass it as `createClient<MapledSchema>(…)` and every read is typed by
|
|
79
|
+
its key; without it the client accepts any key and answers
|
|
80
|
+
`Record<string, unknown>` (or the type you name per call). */
|
|
81
|
+
export type ContentSchema = {
|
|
82
|
+
collections?: object;
|
|
83
|
+
singles?: object;
|
|
84
|
+
};
|
|
85
|
+
type Data = Record<string, unknown>;
|
|
86
|
+
type AnyMap = Record<string, Data>;
|
|
87
|
+
type CollectionsOf<S extends ContentSchema> = S["collections"] extends object ? S["collections"] : AnyMap;
|
|
88
|
+
type SinglesOf<S extends ContentSchema> = S["singles"] extends object ? S["singles"] : AnyMap;
|
|
89
|
+
/** Any key on an untyped client; on a typed one, a key it knows — so an
|
|
90
|
+
explicit `getRecords<Article>("articles")` keeps working either way. */
|
|
91
|
+
type LooseKey<S extends ContentSchema, M> = ContentSchema extends S ? string : keyof M & string;
|
|
92
|
+
/** Writes on a typed client take the collection's own type, never a guess. */
|
|
93
|
+
type LooseData<S extends ContentSchema, T> = ContentSchema extends S ? T : never;
|
|
94
|
+
export interface MapledClient<S extends ContentSchema = ContentSchema> {
|
|
95
|
+
/** Published records of a collection, with filters/sort/pagination. */
|
|
96
|
+
getRecords<K extends keyof CollectionsOf<S> & string>(collection: K, opts?: ListOptions): Promise<ListResult<CollectionsOf<S>[K]>>;
|
|
97
|
+
getRecords<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, opts?: ListOptions): Promise<ListResult<T>>;
|
|
98
|
+
/** One published record by id, or null when it isn't in the release. */
|
|
99
|
+
getRecord<K extends keyof CollectionsOf<S> & string>(collection: K, id: string, opts?: ReadOptions): Promise<MapledRecord<CollectionsOf<S>[K]> | null>;
|
|
100
|
+
getRecord<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, id: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
|
|
101
|
+
/** One published record by the collection's slug field, or null. */
|
|
102
|
+
getRecordBySlug<K extends keyof CollectionsOf<S> & string>(collection: K, slug: string, opts?: ReadOptions): Promise<MapledRecord<CollectionsOf<S>[K]> | null>;
|
|
103
|
+
getRecordBySlug<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, slug: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
|
|
104
|
+
/** The record of a single (e.g. "homepage"), or null before publish. */
|
|
105
|
+
getSingle<K extends keyof SinglesOf<S> & string>(key: K, opts?: ReadOptions): Promise<MapledRecord<SinglesOf<S>[K]> | null>;
|
|
106
|
+
getSingle<T = Data>(key: LooseKey<S, SinglesOf<S>>, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
|
|
107
|
+
/** The release the site is reading right now — the latest published
|
|
108
|
+
one, with the moment it went live — or null before the first
|
|
109
|
+
publish. Never cached: it's the one short-lived pointer of the
|
|
110
|
+
delivery API; pass its number as `release` to pin a render. */
|
|
111
|
+
getRelease(): Promise<{
|
|
112
|
+
release: number;
|
|
113
|
+
publishedAt: string;
|
|
114
|
+
} | null>;
|
|
115
|
+
}
|
|
116
|
+
export interface MapledAppClient<S extends ContentSchema = ContentSchema> {
|
|
117
|
+
list<K extends keyof CollectionsOf<S> & string>(collection: K, opts?: AppListOptions): Promise<{
|
|
118
|
+
records: AppRecord<CollectionsOf<S>[K]>[];
|
|
119
|
+
total: number;
|
|
120
|
+
}>;
|
|
121
|
+
list<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, opts?: AppListOptions): Promise<{
|
|
122
|
+
records: AppRecord<T>[];
|
|
123
|
+
total: number;
|
|
124
|
+
}>;
|
|
125
|
+
get<K extends keyof CollectionsOf<S> & string>(collection: K, id: string): Promise<AppRecord<CollectionsOf<S>[K]> | null>;
|
|
126
|
+
get<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, id: string): Promise<AppRecord<T> | null>;
|
|
127
|
+
create<K extends keyof CollectionsOf<S> & string>(collection: K, data: CollectionsOf<S>[K]): Promise<AppRecord<CollectionsOf<S>[K]>>;
|
|
128
|
+
create<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, data: LooseData<S, T>): Promise<AppRecord<T>>;
|
|
129
|
+
update<K extends keyof CollectionsOf<S> & string>(collection: K, id: string, data: CollectionsOf<S>[K]): Promise<AppRecord<CollectionsOf<S>[K]>>;
|
|
130
|
+
update<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, id: string, data: LooseData<S, T>): Promise<AppRecord<T>>;
|
|
131
|
+
remove(collection: LooseKey<S, CollectionsOf<S>>, id: string): Promise<void>;
|
|
67
132
|
}
|
|
68
|
-
export type MapledClient = ReturnType<typeof createClient>;
|
|
69
|
-
export type MapledAppClient = ReturnType<typeof createAppClient>;
|
|
70
133
|
export type AppRecord<T = Record<string, unknown>> = {
|
|
71
134
|
id: string;
|
|
72
135
|
data: T;
|
|
@@ -79,32 +142,16 @@ export type AppListOptions = {
|
|
|
79
142
|
limit?: number;
|
|
80
143
|
offset?: number;
|
|
81
144
|
};
|
|
82
|
-
export declare function createClient(config: {
|
|
145
|
+
export declare function createClient<S extends ContentSchema = ContentSchema>(config: {
|
|
83
146
|
key: string;
|
|
84
147
|
apiUrl?: string;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
getRecords<T = Record<string, unknown>>(collection: string, opts?: ListOptions): Promise<ListResult<T>>;
|
|
88
|
-
/** One published record by id, or null when it isn't in the release. */
|
|
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>;
|
|
92
|
-
/** The record of a single (e.g. "homepage"), or null before publish. */
|
|
93
|
-
getSingle<T = Record<string, unknown>>(key: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
|
|
94
|
-
};
|
|
148
|
+
release?: number;
|
|
149
|
+
}): MapledClient<S>;
|
|
95
150
|
/** Live data (operational collections): the site's backend reads and
|
|
96
151
|
writes records that take effect immediately — no publish involved.
|
|
97
152
|
Server-side only: the server key must never reach the browser. */
|
|
98
|
-
export declare function createAppClient(config: {
|
|
153
|
+
export declare function createAppClient<S extends ContentSchema = ContentSchema>(config: {
|
|
99
154
|
serverKey: string;
|
|
100
155
|
apiUrl?: string;
|
|
101
|
-
}):
|
|
102
|
-
|
|
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>;
|
|
110
|
-
};
|
|
156
|
+
}): MapledAppClient<S>;
|
|
157
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -21,12 +21,17 @@ export function assetUrl(id, opts = {}) {
|
|
|
21
21
|
}
|
|
22
22
|
export class MapledError extends Error {
|
|
23
23
|
status;
|
|
24
|
-
|
|
24
|
+
/** The API's error code — "NOT_FOUND", "INVALID_QUERY", "RELEASE_NOT_FOUND", … */
|
|
25
|
+
code;
|
|
26
|
+
constructor(status, message, code) {
|
|
25
27
|
super(message);
|
|
26
28
|
this.name = "MapledError";
|
|
27
29
|
this.status = status;
|
|
30
|
+
this.code = code;
|
|
28
31
|
}
|
|
29
32
|
}
|
|
33
|
+
const isRelease = (n) => Number.isInteger(n) && n >= 1;
|
|
34
|
+
const badRelease = () => new Error("Mapled: release must be a positive whole number.");
|
|
30
35
|
/** The draft session cookie set by the preview route. When Next draft
|
|
31
36
|
mode is on, every read switches to live drafts, uncached. Outside a
|
|
32
37
|
Next request context this quietly resolves to null. */
|
|
@@ -46,9 +51,33 @@ export function createClient(config) {
|
|
|
46
51
|
const base = (config.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
|
|
47
52
|
if (!config.key)
|
|
48
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
|
+
}
|
|
49
72
|
async function call(path, search, opts, cacheTags) {
|
|
50
|
-
const qs = search.toString();
|
|
51
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();
|
|
52
81
|
const init = draft
|
|
53
82
|
? {
|
|
54
83
|
headers: { "x-mapled-key": config.key, "x-mapled-draft": draft },
|
|
@@ -57,23 +86,15 @@ export function createClient(config) {
|
|
|
57
86
|
: {
|
|
58
87
|
headers: { "x-mapled-key": config.key },
|
|
59
88
|
next: {
|
|
60
|
-
|
|
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),
|
|
61
92
|
tags: ["mapled", ...cacheTags, ...(opts.tags ?? [])],
|
|
62
93
|
},
|
|
63
94
|
};
|
|
64
95
|
const res = await fetch(`${base}${path}${qs ? `?${qs}` : ""}`, init);
|
|
65
|
-
if (!res.ok)
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
const parsed = (await res.json());
|
|
69
|
-
if (parsed.error?.message)
|
|
70
|
-
message = parsed.error.message;
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
/* non-JSON error body */
|
|
74
|
-
}
|
|
75
|
-
throw new MapledError(res.status, message);
|
|
76
|
-
}
|
|
96
|
+
if (!res.ok)
|
|
97
|
+
throw await failure(res);
|
|
77
98
|
return { status: res.status, body: (await res.json()) };
|
|
78
99
|
}
|
|
79
100
|
/** expand / fields on every read. */
|
|
@@ -90,13 +111,14 @@ export function createClient(config) {
|
|
|
90
111
|
return body.record;
|
|
91
112
|
}
|
|
92
113
|
catch (err) {
|
|
93
|
-
|
|
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") {
|
|
94
116
|
return null;
|
|
117
|
+
}
|
|
95
118
|
throw err;
|
|
96
119
|
}
|
|
97
120
|
};
|
|
98
|
-
|
|
99
|
-
/** Published records of a collection, with filters/sort/pagination. */
|
|
121
|
+
const client = {
|
|
100
122
|
async getRecords(collection, opts = {}) {
|
|
101
123
|
const search = new URLSearchParams();
|
|
102
124
|
for (const [k, v] of Object.entries(opts.filter ?? {})) {
|
|
@@ -121,19 +143,30 @@ export function createClient(config) {
|
|
|
121
143
|
const { body } = await call(`/v1/delivery/collections/${encodeURIComponent(collection)}/records`, search, opts, [`mapled:${collection}`]);
|
|
122
144
|
return body;
|
|
123
145
|
},
|
|
124
|
-
/** One published record by id, or null when it isn't in the release. */
|
|
125
146
|
getRecord(collection, id, opts = {}) {
|
|
126
147
|
return one(`/v1/delivery/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`, opts, `mapled:${collection}`);
|
|
127
148
|
},
|
|
128
|
-
/** One published record by the collection's slug field, or null. */
|
|
129
149
|
getRecordBySlug(collection, slug, opts = {}) {
|
|
130
150
|
return one(`/v1/delivery/collections/${encodeURIComponent(collection)}/records/by-slug/${encodeURIComponent(slug)}`, opts, `mapled:${collection}`);
|
|
131
151
|
},
|
|
132
|
-
/** The record of a single (e.g. "homepage"), or null before publish. */
|
|
133
152
|
getSingle(key, opts = {}) {
|
|
134
153
|
return one(`/v1/delivery/singles/${encodeURIComponent(key)}`, opts, `mapled:${key}`);
|
|
135
154
|
},
|
|
155
|
+
async getRelease() {
|
|
156
|
+
const res = await fetch(`${base}/v1/delivery/release`, {
|
|
157
|
+
headers: { "x-mapled-key": config.key },
|
|
158
|
+
cache: "no-store",
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok)
|
|
161
|
+
throw await failure(res);
|
|
162
|
+
const body = (await res.json());
|
|
163
|
+
if (body.release === null || body.publishedAt === null)
|
|
164
|
+
return null;
|
|
165
|
+
return { release: body.release, publishedAt: body.publishedAt };
|
|
166
|
+
},
|
|
136
167
|
};
|
|
168
|
+
// the same functions behind both overloads: the typed one only narrows
|
|
169
|
+
return client;
|
|
137
170
|
}
|
|
138
171
|
/** Live data (operational collections): the site's backend reads and
|
|
139
172
|
writes records that take effect immediately — no publish involved.
|
|
@@ -168,7 +201,7 @@ export function createAppClient(config) {
|
|
|
168
201
|
return (await res.json());
|
|
169
202
|
}
|
|
170
203
|
const collectionPath = (c) => `/v1/app/collections/${encodeURIComponent(c)}/records`;
|
|
171
|
-
|
|
204
|
+
const client = {
|
|
172
205
|
async list(collection, opts = {}) {
|
|
173
206
|
const search = new URLSearchParams();
|
|
174
207
|
for (const [k, v] of Object.entries(opts.filter ?? {})) {
|
|
@@ -205,4 +238,5 @@ export function createAppClient(config) {
|
|
|
205
238
|
await call("DELETE", `${collectionPath(collection)}/${encodeURIComponent(id)}`);
|
|
206
239
|
},
|
|
207
240
|
};
|
|
241
|
+
return client;
|
|
208
242
|
}
|
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.6.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
|
}
|