@manablox/api-public 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,19 @@
1
+ # `@manablox/api-public`
2
+
3
+ The delivery REST API under `/v1`: an oRPC router with no principal in its context — the read-only guarantee is a property of the type — plus the serialiser that inlines relations on `expand` and the `/types` endpoint the SDK generates types from.
4
+
5
+ ## Exports
6
+
7
+ - `publicRouter` / `PublicRouter`
8
+ - `PublicContext`
9
+ - `serializeContent`, `serializeContents`, `serializeAsset`, `parseExpand`
10
+
11
+ ## Depends on
12
+
13
+ @manablox/services, @orpc/server, zod
14
+
15
+ ## Test
16
+
17
+ ```sh
18
+ pnpm --filter @manablox/api-public test
19
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manablox/api-public",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -11,16 +11,16 @@
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "dependencies": {
14
- "@manablox/core": "0.1.0",
15
- "@manablox/db": "0.1.0",
16
- "@manablox/media": "0.1.0",
17
- "@manablox/services": "0.1.0",
14
+ "@manablox/core": "0.2.0",
15
+ "@manablox/db": "0.2.0",
16
+ "@manablox/media": "0.2.0",
17
+ "@manablox/services": "0.2.0",
18
18
  "@orpc/server": "^1.15.0",
19
19
  "zod": "^4.5.4"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@manablox/config-typescript": "0.0.0",
23
- "@manablox/fields": "0.1.0",
23
+ "@manablox/fields": "0.2.0",
24
24
  "@types/node": "^26.4.1",
25
25
  "typescript": "^7.0.2",
26
26
  "vitest": "^5.0.0"
package/src/context.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Manablox } from '@manablox/core';
2
2
  import type { Repositories } from '@manablox/db';
3
3
  import type { MediaService } from '@manablox/media';
4
- import type { Loaders } from '@manablox/services';
4
+ import type { Loaders, MenuService } from '@manablox/services';
5
5
 
6
6
  /**
7
7
  * The delivery context.
@@ -15,6 +15,7 @@ export interface PublicContext {
15
15
  manablox: Manablox;
16
16
  repos: Repositories;
17
17
  media: MediaService;
18
+ menus: MenuService;
18
19
  loaders: Loaders;
19
20
  /** The one space this instance serves. Never taken from the request. */
20
21
  spaceId: string;
package/src/router.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { ManabloxError, resolveGraphQL } from '@manablox/core';
1
+ import { ManabloxError, resolveGraphQL, TRANSPORT_CODE } from '@manablox/core';
2
+ import type { ContentRow } from '@manablox/db';
3
+ import { type PublicMenuItem, toPublicListQuery } from '@manablox/services';
2
4
  import { ORPCError, os } from '@orpc/server';
3
5
  import { z } from 'zod';
4
6
  import type { PublicContext } from './context.js';
@@ -22,18 +24,10 @@ const base = os.$context<PublicContext>().use(async ({ next }) => {
22
24
  return await next();
23
25
  } catch (error) {
24
26
  if (!ManabloxError.is(error)) throw error;
25
- const code = (
26
- {
27
- validation: 'BAD_REQUEST',
28
- bad_request: 'BAD_REQUEST',
29
- not_found: 'NOT_FOUND',
30
- conflict: 'CONFLICT',
31
- forbidden: 'FORBIDDEN',
32
- unauthorized: 'UNAUTHORIZED',
33
- internal: 'INTERNAL_SERVER_ERROR',
34
- } as const
35
- )[error.kind];
36
- throw new ORPCError(code, { message: error.key, data: { key: error.key } });
27
+ throw new ORPCError(TRANSPORT_CODE[error.kind], {
28
+ message: error.key,
29
+ data: { key: error.key },
30
+ });
37
31
  }
38
32
  });
39
33
 
@@ -67,9 +61,39 @@ const assetSchema: z.ZodType<PublicAsset> = z.object({
67
61
  height: z.number().nullable(),
68
62
  alt: z.string().nullable(),
69
63
  title: z.string().nullable(),
64
+ focalPoint: z.object({ x: z.number(), y: z.number() }).nullable(),
65
+ crop: z
66
+ .object({ left: z.number(), top: z.number(), width: z.number(), height: z.number() })
67
+ .nullable(),
70
68
  variants: z.record(z.string(), z.string()),
71
69
  });
72
70
 
71
+ /** A menu entry for REST: the document inlined, sub-entries nested. */
72
+ interface PublicMenuEntry {
73
+ id: string;
74
+ label: string;
75
+ url: string | null;
76
+ content: PublicContent | null;
77
+ children: PublicMenuEntry[];
78
+ }
79
+
80
+ const menuItemSchema: z.ZodType<PublicMenuEntry> = z.lazy(() =>
81
+ z.object({
82
+ id: z.string(),
83
+ label: z.string(),
84
+ url: z.string().nullable(),
85
+ content: contentSchema.nullable(),
86
+ children: z.array(menuItemSchema),
87
+ }),
88
+ );
89
+
90
+ const menuSchema = z.object({
91
+ id: z.string(),
92
+ name: z.string(),
93
+ machineName: z.string(),
94
+ items: z.array(menuItemSchema),
95
+ });
96
+
73
97
  const listSchema = z.object({
74
98
  items: z.array(contentSchema),
75
99
  total: z.number(),
@@ -103,22 +127,15 @@ export const publicRouter = {
103
127
  )
104
128
  .output(listSchema)
105
129
  .handler(async ({ input, context }) => {
106
- const typeIds = input.type
107
- ? [context.manablox.contentTypes.getByName(input.type, context.spaceId).id]
108
- : undefined;
109
-
130
+ const query = toPublicListQuery(context.manablox.contentTypes, input, {
131
+ spaceId: context.spaceId,
132
+ locale: context.locale,
133
+ });
134
+ // The published projection, always. There is no other option on this router.
110
135
  const page = await context.repos.content.list(
111
- {
112
- spaceId: context.spaceId,
113
- locale: input.locale ?? context.locale,
114
- ...(typeIds ? { typeIds } : {}),
115
- ...(input.parentId ? { parentId: input.parentId } : {}),
116
- ...(input.under ? { under: input.under } : {}),
117
- ...(input.search ? { search: input.search } : {}),
118
- },
119
- { limit: input.limit, offset: input.offset },
120
- [],
121
- // The published projection, always. There is no other option on this router.
136
+ query.filter,
137
+ query.pagination,
138
+ query.sorts,
122
139
  true,
123
140
  );
124
141
 
@@ -161,41 +178,66 @@ export const publicRouter = {
161
178
  }),
162
179
  )
163
180
  .output(contentSchema)
164
- .handler(async ({ input, context }) => {
165
- const permalink = input.path.replace(/^\/+|\/+$/g, '');
166
- const row = await context.repos.content.findByPermalink(
167
- context.spaceId,
168
- input.locale ?? context.locale,
169
- permalink,
170
- true,
171
- );
172
- if (!row) throw ManabloxError.notFound('content.notFound', { permalink });
173
- return serializeContent(row, context, parseExpand(input.expand));
174
- }),
181
+ .handler(({ input, context }) => resolvePermalink(input.path, input, context)),
182
+
183
+ // `{+path}` needs at least one character, so the empty path — the space's home page,
184
+ // which the repository resolves through `settings.homeContentId` — gets a route of
185
+ // its own. The SDK calls it for `byPermalink('/')`.
186
+ home: base
187
+ .route({
188
+ method: 'GET',
189
+ path: '/permalink',
190
+ summary: "Resolve the space's home page",
191
+ tags: ['content'],
192
+ })
193
+ .input(z.object({ locale: z.string().max(10).optional(), expand: expandArg }))
194
+ .output(contentSchema)
195
+ .handler(({ input, context }) => resolvePermalink('', input, context)),
175
196
 
176
197
  menu: base
177
- .route({ method: 'GET', path: '/menu', summary: 'The navigation tree', tags: ['content'] })
198
+ .route({
199
+ method: 'GET',
200
+ path: '/menus/{name}',
201
+ summary: 'A navigation menu by name',
202
+ tags: ['content'],
203
+ })
178
204
  .input(
179
205
  z.object({
180
- rootId: uuid.optional(),
206
+ name: z
207
+ .string()
208
+ .regex(/^[a-z][a-z0-9_-]*$/)
209
+ .max(64),
181
210
  locale: z.string().max(10).optional(),
182
211
  expand: expandArg,
183
212
  }),
184
213
  )
185
- .output(z.object({ items: z.array(contentSchema) }))
214
+ .output(menuSchema)
186
215
  .handler(async ({ input, context }) => {
187
- const page = await context.repos.content.list(
188
- {
189
- spaceId: context.spaceId,
190
- locale: input.locale ?? context.locale,
191
- visibleInMenu: true,
192
- ...(input.rootId ? { under: input.rootId } : {}),
193
- },
194
- { limit: 500, offset: 0 },
195
- [{ by: 'position', direction: 'asc' }],
216
+ const menu = await context.menus.resolve(
217
+ context.spaceId,
218
+ input.name,
219
+ input.locale ?? context.locale,
196
220
  true,
197
221
  );
198
- return { items: await serializeContents(page.items, context, parseExpand(input.expand)) };
222
+ if (!menu) throw ManabloxError.notFound('menu.notFound', { id: input.name });
223
+
224
+ // One batch for every document in the tree, then the tree is rebuilt around them.
225
+ const rows = collectMenuRows(menu.items);
226
+ const serialised = await serializeContents(rows, context, parseExpand(input.expand));
227
+ const byId = new Map(rows.map((row, index) => [row.id, serialised[index] as PublicContent]));
228
+ const toEntry = (item: PublicMenuItem): PublicMenuEntry => ({
229
+ id: item.id,
230
+ label: item.label,
231
+ url: item.url,
232
+ content: item.content ? (byId.get(item.content.id) ?? null) : null,
233
+ children: item.children.map(toEntry),
234
+ });
235
+ return {
236
+ id: menu.id,
237
+ name: menu.name,
238
+ machineName: menu.machineName,
239
+ items: menu.items.map(toEntry),
240
+ };
199
241
  }),
200
242
 
201
243
  asset: base
@@ -296,3 +338,28 @@ function blockTypeNames(context: PublicContext, settings: unknown): string[] | u
296
338
  .map((id) => context.manablox.contentTypes.tryGet(id)?.name)
297
339
  .filter((name): name is string => Boolean(name));
298
340
  }
341
+
342
+ function collectMenuRows(items: PublicMenuItem[]): ContentRow[] {
343
+ const out: ContentRow[] = [];
344
+ for (const item of items) {
345
+ if (item.content) out.push(item.content);
346
+ out.push(...collectMenuRows(item.children));
347
+ }
348
+ return out;
349
+ }
350
+
351
+ async function resolvePermalink(
352
+ path: string,
353
+ input: { locale?: string | undefined; expand?: string | undefined },
354
+ context: PublicContext,
355
+ ): Promise<PublicContent> {
356
+ const permalink = path.replace(/^\/+|\/+$/g, '');
357
+ const row = await context.repos.content.findByPermalink(
358
+ context.spaceId,
359
+ input.locale ?? context.locale,
360
+ permalink,
361
+ true,
362
+ );
363
+ if (!row) throw ManabloxError.notFound('content.notFound', { permalink });
364
+ return serializeContent(row, context, parseExpand(input.expand));
365
+ }
package/src/serialize.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import {
2
+ type AssetCrop,
2
3
  type ContentTypeDefinition,
4
+ type FocalPoint,
3
5
  fieldBlocks,
4
6
  type Manablox,
5
7
  resolveGraphQL,
6
8
  } from '@manablox/core';
7
9
  import type { AssetRow, ContentRow, UserRow } from '@manablox/db';
8
- import { absoluteMediaUrl } from '@manablox/media';
10
+ import { absoluteMediaUrl, readImageEdits } from '@manablox/media';
9
11
  import type { PublicContext } from './context.js';
10
12
 
11
13
  export interface PublicAsset {
@@ -18,6 +20,14 @@ export interface PublicAsset {
18
20
  height: number | null;
19
21
  alt: string | null;
20
22
  title: string | null;
23
+ /**
24
+ * Where the subject is, as fractions of the (cropped) image, or `null` for centred.
25
+ * Variants already honour it; it is here for a frontend that renders the original
26
+ * itself, e.g. as CSS `object-position`.
27
+ */
28
+ focalPoint: FocalPoint | null;
29
+ /** The editor's crop in the original's pixels, or `null`. Variants are cut to it. */
30
+ crop: AssetCrop | null;
21
31
  /**
22
32
  * Signed transform URLs, keyed by preset name.
23
33
  *
@@ -119,6 +129,7 @@ export function serializeAsset(asset: AssetRow, ctx: PublicContext): PublicAsset
119
129
  // Absolute: a delivery consumer is on its own origin, and a root-relative `/media/…`
120
130
  // would resolve against the *frontend* rather than the API.
121
131
  const base = ctx.manablox.config.server.publicUrl;
132
+ const edits = readImageEdits(asset.meta);
122
133
 
123
134
  return {
124
135
  id: asset.id,
@@ -131,6 +142,8 @@ export function serializeAsset(asset: AssetRow, ctx: PublicContext): PublicAsset
131
142
  height: asset.height,
132
143
  alt: asset.alt,
133
144
  title: asset.title,
145
+ focalPoint: edits.focalPoint ?? null,
146
+ crop: edits.crop ?? null,
134
147
  };
135
148
  }
136
149
 
@@ -70,6 +70,7 @@ function context(): PublicContext {
70
70
  return {
71
71
  manablox,
72
72
  repos: {} as never,
73
+ menus: {} as never,
73
74
  media: { urlFor: (row: { id: string }) => `https://cdn.test/${row.id}` } as never,
74
75
  loaders: {
75
76
  asset: loader((id) => asset(id), 'asset'),