@manablox/api-public 0.2.0 → 0.3.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.
@@ -0,0 +1,183 @@
1
+ import { AssetCrop, FocalPoint, Manablox } from "@manablox/core";
2
+ import { Loaders, MenuService } from "@manablox/services";
3
+ import { z } from "zod";
4
+ import { MediaService } from "@manablox/media";
5
+ import { AssetRow, ContentRow, Repositories } from "@manablox/db";
6
+ //#region src/context.d.ts
7
+ /**
8
+ * The delivery context.
9
+ *
10
+ * Compare `RpcContext`: no `auth`, no `apiKeys`, no `principal`, no `content` service.
11
+ * A procedure here cannot authenticate a caller or write, because the context it runs
12
+ * in has nothing to do it with — the read-only guarantee is a property of the type, not
13
+ * a rule contributors have to remember.
14
+ */
15
+ export interface PublicContext {
16
+ manablox: Manablox;
17
+ repos: Repositories;
18
+ media: MediaService;
19
+ menus: MenuService;
20
+ loaders: Loaders;
21
+ /** The one space this instance serves. Never taken from the request. */
22
+ spaceId: string;
23
+ locale: string;
24
+ }
25
+ //#endregion
26
+ //#region src/serialize.d.ts
27
+ export interface PublicAsset {
28
+ id: string;
29
+ url: string;
30
+ filename: string;
31
+ mimeType: string;
32
+ size: number;
33
+ width: number | null;
34
+ height: number | null;
35
+ alt: string | null;
36
+ title: string | null;
37
+ /**
38
+ * Where the subject is, as fractions of the (cropped) image, or `null` for centred.
39
+ * Variants already honour it; it is here for a frontend that renders the original
40
+ * itself, e.g. as CSS `object-position`.
41
+ */
42
+ focalPoint: FocalPoint | null;
43
+ /** The editor's crop in the original's pixels, or `null`. Variants are cut to it. */
44
+ crop: AssetCrop | null;
45
+ /**
46
+ * Signed transform URLs, keyed by preset name.
47
+ *
48
+ * The signature is an HMAC of the instance's media secret, so a client cannot mint
49
+ * one — an unsigned transform request is a resize amplifier and is refused. That is
50
+ * why the URLs are served rather than constructed in the SDK.
51
+ */
52
+ variants: Record<string, string>;
53
+ }
54
+ export interface PublicUser {
55
+ id: string;
56
+ name: string;
57
+ image: string | null;
58
+ }
59
+ export interface PublicContent {
60
+ id: string;
61
+ type: string;
62
+ title: string;
63
+ slug: string;
64
+ permalink: string | null;
65
+ locale: string;
66
+ parentId: string | null;
67
+ publishedAt: string | null;
68
+ updatedAt: string;
69
+ /**
70
+ * A plain map keyed by field name — `{ title, summary, components: [...] }`.
71
+ *
72
+ * GraphQL's per-type object is richer; REST's job is to be pleasant to consume with
73
+ * no schema knowledge, which a keyed map is and a tagged union is not.
74
+ */
75
+ fields: Record<string, unknown>;
76
+ }
77
+ export interface PublicBlock {
78
+ blockId: string;
79
+ type: string;
80
+ fields: Record<string, unknown>;
81
+ }
82
+ /** Which relation fields to inline rather than leave as ids. */
83
+ export type ExpandSet = ReadonlySet<string>;
84
+ export declare function parseExpand(raw: string | undefined): ExpandSet;
85
+ /**
86
+ * Serialises rows for the REST surface.
87
+ *
88
+ * Three passes, not one: collect every id the expansion will need, load each target in
89
+ * a single batch, then build the output synchronously. Serialising and loading in one
90
+ * pass would still be correct — DataLoader batches within a tick — but it would depend
91
+ * on nothing in the walk ever awaiting, which is exactly the kind of invariant that
92
+ * quietly breaks and reintroduces the N+1 this whole layer exists to avoid.
93
+ */
94
+ export declare function serializeContents(rows: ContentRow[], ctx: PublicContext, expand: ExpandSet): Promise<PublicContent[]>;
95
+ export declare function serializeContent(row: ContentRow, ctx: PublicContext, expand: ExpandSet): Promise<PublicContent>;
96
+ export declare function serializeAsset(asset: AssetRow, ctx: PublicContext): PublicAsset;
97
+ //#endregion
98
+ //#region src/router.d.ts
99
+ /** A menu entry for REST: the document inlined, sub-entries nested. */
100
+ interface PublicMenuEntry {
101
+ id: string;
102
+ label: string;
103
+ url: string | null;
104
+ content: PublicContent | null;
105
+ children: PublicMenuEntry[];
106
+ }
107
+ /**
108
+ * The public delivery API: one definition yielding REST, an OpenAPI document and the
109
+ * SDK's types. No `spaceId` appears anywhere — the space is pinned in the context.
110
+ */
111
+ export declare const publicRouter: {
112
+ list: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<PublicContext & Record<never, never>, PublicContext, PublicContext>, import("@orpc/server").MergedCurrentContext<PublicContext, Record<never, never>>, z.ZodObject<{
113
+ type: z.ZodOptional<z.ZodString>;
114
+ parentId: z.ZodOptional<z.ZodString>;
115
+ under: z.ZodOptional<z.ZodString>;
116
+ search: z.ZodOptional<z.ZodString>;
117
+ locale: z.ZodOptional<z.ZodString>;
118
+ limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
119
+ offset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
120
+ expand: z.ZodOptional<z.ZodString>;
121
+ }, z.core.$strip>, z.ZodObject<{
122
+ items: z.ZodArray<z.ZodType<PublicContent, unknown, z.core.$ZodTypeInternals<PublicContent, unknown>>>;
123
+ total: z.ZodNumber;
124
+ limit: z.ZodNumber;
125
+ offset: z.ZodNumber;
126
+ }, z.core.$strip>, Record<never, never>, Record<never, never>>;
127
+ get: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<PublicContext & Record<never, never>, PublicContext, PublicContext>, import("@orpc/server").MergedCurrentContext<PublicContext, Record<never, never>>, z.ZodObject<{
128
+ id: z.ZodString;
129
+ expand: z.ZodOptional<z.ZodString>;
130
+ }, z.core.$strip>, z.ZodType<PublicContent, unknown, z.core.$ZodTypeInternals<PublicContent, unknown>>, Record<never, never>, Record<never, never>>;
131
+ byPermalink: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<PublicContext & Record<never, never>, PublicContext, PublicContext>, import("@orpc/server").MergedCurrentContext<PublicContext, Record<never, never>>, z.ZodObject<{
132
+ path: z.ZodString;
133
+ locale: z.ZodOptional<z.ZodString>;
134
+ expand: z.ZodOptional<z.ZodString>;
135
+ }, z.core.$strip>, z.ZodType<PublicContent, unknown, z.core.$ZodTypeInternals<PublicContent, unknown>>, Record<never, never>, Record<never, never>>;
136
+ home: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<PublicContext & Record<never, never>, PublicContext, PublicContext>, import("@orpc/server").MergedCurrentContext<PublicContext, Record<never, never>>, z.ZodObject<{
137
+ locale: z.ZodOptional<z.ZodString>;
138
+ expand: z.ZodOptional<z.ZodString>;
139
+ }, z.core.$strip>, z.ZodType<PublicContent, unknown, z.core.$ZodTypeInternals<PublicContent, unknown>>, Record<never, never>, Record<never, never>>;
140
+ menu: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<PublicContext & Record<never, never>, PublicContext, PublicContext>, import("@orpc/server").MergedCurrentContext<PublicContext, Record<never, never>>, z.ZodObject<{
141
+ name: z.ZodString;
142
+ locale: z.ZodOptional<z.ZodString>;
143
+ expand: z.ZodOptional<z.ZodString>;
144
+ }, z.core.$strip>, z.ZodObject<{
145
+ id: z.ZodString;
146
+ name: z.ZodString;
147
+ machineName: z.ZodString;
148
+ items: z.ZodArray<z.ZodType<PublicMenuEntry, unknown, z.core.$ZodTypeInternals<PublicMenuEntry, unknown>>>;
149
+ }, z.core.$strip>, Record<never, never>, Record<never, never>>;
150
+ asset: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<PublicContext & Record<never, never>, PublicContext, PublicContext>, import("@orpc/server").MergedCurrentContext<PublicContext, Record<never, never>>, z.ZodObject<{
151
+ id: z.ZodString;
152
+ }, z.core.$strip>, z.ZodType<PublicAsset, unknown, z.core.$ZodTypeInternals<PublicAsset, unknown>>, Record<never, never>, Record<never, never>>;
153
+ types: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<PublicContext & Record<never, never>, PublicContext, PublicContext>, import("@orpc/server").MergedCurrentContext<PublicContext, Record<never, never>>, z.ZodOptional<z.ZodObject<{}, z.core.$strip>>, z.ZodObject<{
154
+ types: z.ZodArray<z.ZodObject<{
155
+ name: z.ZodString;
156
+ label: z.ZodString;
157
+ kind: z.ZodEnum<{
158
+ block: "block";
159
+ content: "content";
160
+ }>;
161
+ fields: z.ZodArray<z.ZodObject<{
162
+ name: z.ZodString;
163
+ type: z.ZodString;
164
+ required: z.ZodBoolean;
165
+ list: z.ZodBoolean;
166
+ kind: z.ZodEnum<{
167
+ block: "block";
168
+ ref: "ref";
169
+ scalar: "scalar";
170
+ }>;
171
+ scalar: z.ZodOptional<z.ZodString>;
172
+ target: z.ZodOptional<z.ZodEnum<{
173
+ asset: "asset";
174
+ content: "content";
175
+ user: "user";
176
+ }>>;
177
+ blockTypes: z.ZodOptional<z.ZodArray<z.ZodString>>;
178
+ }, z.core.$strip>>;
179
+ }, z.core.$strip>>;
180
+ }, z.core.$strip>, Record<never, never>, Record<never, never>>;
181
+ };
182
+ export type PublicRouter = typeof publicRouter;
183
+ //#endregion
package/dist/index.js ADDED
@@ -0,0 +1,424 @@
1
+ import { ManabloxError, TRANSPORT_CODE, fieldBlocks, resolveGraphQL } from "@manablox/core";
2
+ import { toPublicListQuery } from "@manablox/services";
3
+ import { ORPCError, os } from "@orpc/server";
4
+ import { z } from "zod";
5
+ import { absoluteMediaUrl, readImageEdits } from "@manablox/media";
6
+ //#region src/serialize.ts
7
+ function parseExpand(raw) {
8
+ if (!raw) return /* @__PURE__ */ new Set();
9
+ return new Set(raw.split(",").map((name) => name.trim()).filter(Boolean));
10
+ }
11
+ /**
12
+ * Serialises rows for the REST surface.
13
+ *
14
+ * Three passes, not one: collect every id the expansion will need, load each target in
15
+ * a single batch, then build the output synchronously. Serialising and loading in one
16
+ * pass would still be correct — DataLoader batches within a tick — but it would depend
17
+ * on nothing in the walk ever awaiting, which is exactly the kind of invariant that
18
+ * quietly breaks and reintroduces the N+1 this whole layer exists to avoid.
19
+ */
20
+ async function serializeContents(rows, ctx, expand) {
21
+ const collected = {
22
+ content: /* @__PURE__ */ new Set(),
23
+ asset: /* @__PURE__ */ new Set(),
24
+ user: /* @__PURE__ */ new Set()
25
+ };
26
+ for (const row of rows) collectRow(row, ctx.manablox, expand, collected);
27
+ const resolved = await resolveAll(collected, ctx);
28
+ return rows.map((row) => buildContent(row, ctx, expand, resolved));
29
+ }
30
+ async function serializeContent(row, ctx, expand) {
31
+ const [only] = await serializeContents([row], ctx, expand);
32
+ return only;
33
+ }
34
+ function serializeAsset(asset, ctx) {
35
+ const base = ctx.manablox.config.server.publicUrl;
36
+ const edits = readImageEdits(asset.meta);
37
+ return {
38
+ id: asset.id,
39
+ url: absoluteMediaUrl(ctx.media.urlFor(asset), base),
40
+ variants: buildVariants(asset, ctx),
41
+ filename: asset.filename,
42
+ mimeType: asset.mimeType,
43
+ size: asset.size,
44
+ width: asset.width,
45
+ height: asset.height,
46
+ alt: asset.alt,
47
+ title: asset.title,
48
+ focalPoint: edits.focalPoint ?? null,
49
+ crop: edits.crop ?? null
50
+ };
51
+ }
52
+ /** Each configured preset, at the format the preset itself declares. */
53
+ function buildVariants(asset, ctx) {
54
+ if (!asset.mimeType.startsWith("image/")) return {};
55
+ const presets = ctx.manablox.config.media.presets;
56
+ const base = ctx.manablox.config.server.publicUrl;
57
+ const out = {};
58
+ for (const [name, preset] of Object.entries(presets)) out[name] = absoluteMediaUrl(ctx.media.urlFor(asset, name, preset.format ?? "webp"), base);
59
+ return out;
60
+ }
61
+ function collectRow(row, manablox, expand, into) {
62
+ const type = manablox.contentTypes.tryGet(row.typeId);
63
+ if (!type) return;
64
+ collectFields(type, row.fields, manablox, expand, into, 0);
65
+ }
66
+ function collectFields(type, values, manablox, expand, into, depth) {
67
+ if (depth > 16) return;
68
+ for (const field of type.fields) {
69
+ if (field.readRoles?.length) continue;
70
+ const fieldType = manablox.fieldTypes.tryGet(field.type);
71
+ if (!fieldType) continue;
72
+ const value = values[field.name];
73
+ const spec = resolveGraphQL(fieldType, field.settings);
74
+ if (spec.type.kind === "ref" && expand.has(field.name)) for (const id of toIds(value)) into[spec.type.target].add(id);
75
+ if (fieldType.nested) for (const block of fieldBlocks(fieldType, value, field.settings)) {
76
+ const blockType = manablox.contentTypes.tryGet(block.type);
77
+ if (blockType) collectFields(blockType, block.fields, manablox, expand, into, depth + 1);
78
+ }
79
+ }
80
+ }
81
+ async function resolveAll(collected, ctx) {
82
+ const [contents, assets, users] = await Promise.all([
83
+ loadMany(ctx.loaders.publishedContent, collected.content),
84
+ loadMany(ctx.loaders.asset, collected.asset),
85
+ loadMany(ctx.loaders.user, collected.user)
86
+ ]);
87
+ return {
88
+ content: contents,
89
+ asset: assets,
90
+ user: users
91
+ };
92
+ }
93
+ async function loadMany(loader, ids) {
94
+ if (ids.size === 0) return /* @__PURE__ */ new Map();
95
+ const rows = await loader.loadMany([...ids]);
96
+ const map = /* @__PURE__ */ new Map();
97
+ for (const row of rows) if (row && !(row instanceof Error)) map.set(row.id, row);
98
+ return map;
99
+ }
100
+ function buildContent(row, ctx, expand, resolved) {
101
+ const type = ctx.manablox.contentTypes.tryGet(row.typeId);
102
+ return {
103
+ id: row.id,
104
+ type: type?.name ?? "unknown",
105
+ title: row.title,
106
+ slug: row.slug,
107
+ permalink: row.permalink,
108
+ locale: row.locale,
109
+ parentId: row.parentId,
110
+ publishedAt: row.publishedAt ? row.publishedAt.toISOString() : null,
111
+ updatedAt: row.updatedAt.toISOString(),
112
+ fields: type ? buildFields(type, row.fields, ctx, expand, resolved, 0) : {}
113
+ };
114
+ }
115
+ function buildFields(type, values, ctx, expand, resolved, depth) {
116
+ const out = {};
117
+ if (depth > 16) return out;
118
+ for (const field of type.fields) {
119
+ if (field.readRoles?.length) continue;
120
+ const fieldType = ctx.manablox.fieldTypes.tryGet(field.type);
121
+ if (!fieldType) continue;
122
+ const value = values[field.name];
123
+ const spec = resolveGraphQL(fieldType, field.settings);
124
+ if (fieldType.nested) {
125
+ const blocks = fieldBlocks(fieldType, value, field.settings).map((block) => {
126
+ const blockType = ctx.manablox.contentTypes.tryGet(block.type);
127
+ return {
128
+ blockId: block.blockId,
129
+ type: blockType?.name ?? "unknown",
130
+ fields: blockType ? buildFields(blockType, block.fields, ctx, expand, resolved, depth + 1) : {}
131
+ };
132
+ });
133
+ out[field.name] = spec.list === false ? blocks[0] ?? null : blocks;
134
+ continue;
135
+ }
136
+ if (spec.type.kind === "ref" && expand.has(field.name)) {
137
+ const inlined = toIds(value).map((id) => inline(spec.type.kind === "ref" ? spec.type.target : "content", id, ctx, resolved)).filter((entry) => entry !== null);
138
+ out[field.name] = spec.list ? inlined : inlined[0] ?? null;
139
+ continue;
140
+ }
141
+ out[field.name] = value ?? null;
142
+ }
143
+ return out;
144
+ }
145
+ function inline(target, id, ctx, resolved) {
146
+ if (target === "asset") {
147
+ const asset = resolved.asset.get(id);
148
+ return asset ? serializeAsset(asset, ctx) : null;
149
+ }
150
+ if (target === "user") {
151
+ const user = resolved.user.get(id);
152
+ return user ? {
153
+ id: user.id,
154
+ name: user.name,
155
+ image: user.image
156
+ } : null;
157
+ }
158
+ const row = resolved.content.get(id);
159
+ if (!row) return null;
160
+ return {
161
+ id: row.id,
162
+ type: ctx.manablox.contentTypes.tryGet(row.typeId)?.name ?? "unknown",
163
+ title: row.title,
164
+ permalink: row.permalink,
165
+ locale: row.locale
166
+ };
167
+ }
168
+ function toIds(value) {
169
+ if (typeof value === "string") return [value];
170
+ if (Array.isArray(value)) return value.filter((entry) => typeof entry === "string");
171
+ return [];
172
+ }
173
+ //#endregion
174
+ //#region src/router.ts
175
+ /**
176
+ * The base procedure.
177
+ *
178
+ * There is no `authed` and no `scoped` sibling here, and that is the point: a public
179
+ * procedure has no principal to check, so the only middleware is the error mapping.
180
+ */
181
+ const base = os.$context().use(async ({ next }) => {
182
+ try {
183
+ return await next();
184
+ } catch (error) {
185
+ if (!ManabloxError.is(error)) throw error;
186
+ throw new ORPCError(TRANSPORT_CODE[error.kind], {
187
+ message: error.key,
188
+ data: { key: error.key }
189
+ });
190
+ }
191
+ });
192
+ const uuid = z.string().uuid();
193
+ const expandArg = z.string().max(200).optional().describe("Comma-separated relation fields to inline, e.g. `hero,author`.");
194
+ const contentSchema = z.object({
195
+ id: z.string(),
196
+ type: z.string(),
197
+ title: z.string(),
198
+ slug: z.string(),
199
+ permalink: z.string().nullable(),
200
+ locale: z.string(),
201
+ parentId: z.string().nullable(),
202
+ publishedAt: z.string().nullable(),
203
+ updatedAt: z.string(),
204
+ fields: z.record(z.string(), z.unknown())
205
+ });
206
+ const assetSchema = z.object({
207
+ id: z.string(),
208
+ url: z.string(),
209
+ filename: z.string(),
210
+ mimeType: z.string(),
211
+ size: z.number(),
212
+ width: z.number().nullable(),
213
+ height: z.number().nullable(),
214
+ alt: z.string().nullable(),
215
+ title: z.string().nullable(),
216
+ focalPoint: z.object({
217
+ x: z.number(),
218
+ y: z.number()
219
+ }).nullable(),
220
+ crop: z.object({
221
+ left: z.number(),
222
+ top: z.number(),
223
+ width: z.number(),
224
+ height: z.number()
225
+ }).nullable(),
226
+ variants: z.record(z.string(), z.string())
227
+ });
228
+ const menuItemSchema = z.lazy(() => z.object({
229
+ id: z.string(),
230
+ label: z.string(),
231
+ url: z.string().nullable(),
232
+ content: contentSchema.nullable(),
233
+ children: z.array(menuItemSchema)
234
+ }));
235
+ const menuSchema = z.object({
236
+ id: z.string(),
237
+ name: z.string(),
238
+ machineName: z.string(),
239
+ items: z.array(menuItemSchema)
240
+ });
241
+ const listSchema = z.object({
242
+ items: z.array(contentSchema),
243
+ total: z.number(),
244
+ limit: z.number(),
245
+ offset: z.number()
246
+ });
247
+ /**
248
+ * The public delivery API: one definition yielding REST, an OpenAPI document and the
249
+ * SDK's types. No `spaceId` appears anywhere — the space is pinned in the context.
250
+ */
251
+ const publicRouter = {
252
+ list: base.route({
253
+ method: "GET",
254
+ path: "/content",
255
+ summary: "List published documents",
256
+ tags: ["content"]
257
+ }).input(z.object({
258
+ type: z.string().max(100).optional(),
259
+ parentId: uuid.optional(),
260
+ under: uuid.optional(),
261
+ search: z.string().max(200).optional(),
262
+ locale: z.string().max(10).optional(),
263
+ limit: z.coerce.number().int().min(1).max(100).default(25),
264
+ offset: z.coerce.number().int().min(0).default(0),
265
+ expand: expandArg
266
+ })).output(listSchema).handler(async ({ input, context }) => {
267
+ const query = toPublicListQuery(context.manablox.contentTypes, input, {
268
+ spaceId: context.spaceId,
269
+ locale: context.locale
270
+ });
271
+ const page = await context.repos.content.list(query.filter, query.pagination, query.sorts, true);
272
+ return {
273
+ items: await serializeContents(page.items, context, parseExpand(input.expand)),
274
+ total: page.total,
275
+ limit: page.limit,
276
+ offset: page.offset
277
+ };
278
+ }),
279
+ get: base.route({
280
+ method: "GET",
281
+ path: "/content/{id}",
282
+ summary: "Fetch one published document by id",
283
+ tags: ["content"]
284
+ }).input(z.object({
285
+ id: uuid,
286
+ expand: expandArg
287
+ })).output(contentSchema).handler(async ({ input, context }) => {
288
+ const row = await context.loaders.publishedContent.load(input.id);
289
+ if (!row) throw ManabloxError.notFound("content.notFound", { id: input.id });
290
+ return serializeContent(row, context, parseExpand(input.expand));
291
+ }),
292
+ byPermalink: base.route({
293
+ method: "GET",
294
+ path: "/permalink/{+path}",
295
+ summary: "Resolve a URL path to a document",
296
+ tags: ["content"]
297
+ }).input(z.object({
298
+ path: z.string().max(2e3),
299
+ locale: z.string().max(10).optional(),
300
+ expand: expandArg
301
+ })).output(contentSchema).handler(({ input, context }) => resolvePermalink(input.path, input, context)),
302
+ home: base.route({
303
+ method: "GET",
304
+ path: "/permalink",
305
+ summary: "Resolve the space's home page",
306
+ tags: ["content"]
307
+ }).input(z.object({
308
+ locale: z.string().max(10).optional(),
309
+ expand: expandArg
310
+ })).output(contentSchema).handler(({ input, context }) => resolvePermalink("", input, context)),
311
+ menu: base.route({
312
+ method: "GET",
313
+ path: "/menus/{name}",
314
+ summary: "A navigation menu by name",
315
+ tags: ["content"]
316
+ }).input(z.object({
317
+ name: z.string().regex(/^[a-z][a-z0-9_-]*$/).max(64),
318
+ locale: z.string().max(10).optional(),
319
+ expand: expandArg
320
+ })).output(menuSchema).handler(async ({ input, context }) => {
321
+ const menu = await context.menus.resolve(context.spaceId, input.name, input.locale ?? context.locale, true);
322
+ if (!menu) throw ManabloxError.notFound("menu.notFound", { id: input.name });
323
+ const rows = collectMenuRows(menu.items);
324
+ const serialised = await serializeContents(rows, context, parseExpand(input.expand));
325
+ const byId = new Map(rows.map((row, index) => [row.id, serialised[index]]));
326
+ const toEntry = (item) => ({
327
+ id: item.id,
328
+ label: item.label,
329
+ url: item.url,
330
+ content: item.content ? byId.get(item.content.id) ?? null : null,
331
+ children: item.children.map(toEntry)
332
+ });
333
+ return {
334
+ id: menu.id,
335
+ name: menu.name,
336
+ machineName: menu.machineName,
337
+ items: menu.items.map(toEntry)
338
+ };
339
+ }),
340
+ asset: base.route({
341
+ method: "GET",
342
+ path: "/assets/{id}",
343
+ summary: "Fetch asset metadata",
344
+ tags: ["media"]
345
+ }).input(z.object({ id: uuid })).output(assetSchema).handler(async ({ input, context }) => {
346
+ const asset = await context.loaders.asset.load(input.id);
347
+ if (!asset) throw ManabloxError.notFound("asset.notFound", { id: input.id });
348
+ return serializeAsset(asset, context);
349
+ }),
350
+ types: base.route({
351
+ method: "GET",
352
+ path: "/types",
353
+ summary: "The space content model, for SDK type generation",
354
+ tags: ["schema"]
355
+ }).input(z.object({}).optional()).output(z.object({ types: z.array(z.object({
356
+ name: z.string(),
357
+ label: z.string(),
358
+ kind: z.enum(["content", "block"]),
359
+ fields: z.array(z.object({
360
+ name: z.string(),
361
+ type: z.string(),
362
+ required: z.boolean(),
363
+ list: z.boolean(),
364
+ /** `scalar` carries a GraphQL scalar name, `ref` a target, `block` neither. */
365
+ kind: z.enum([
366
+ "scalar",
367
+ "ref",
368
+ "block"
369
+ ]),
370
+ scalar: z.string().optional(),
371
+ target: z.enum([
372
+ "content",
373
+ "asset",
374
+ "user"
375
+ ]).optional(),
376
+ blockTypes: z.array(z.string()).optional()
377
+ }))
378
+ })) })).handler(({ context }) => {
379
+ const registry = context.manablox.contentTypes;
380
+ return { types: registry.all.filter((type) => type.spaceId === null || type.spaceId === context.spaceId).map((type) => ({
381
+ name: type.name,
382
+ label: type.label,
383
+ kind: type.kind,
384
+ fields: type.fields.filter((field) => !field.readRoles?.length).flatMap((field) => {
385
+ const fieldType = registry.fieldTypes.tryGet(field.type);
386
+ if (!fieldType) return [];
387
+ const spec = resolveGraphQL(fieldType, field.settings);
388
+ const blockTypes = blockTypeNames(context, field.settings);
389
+ return [{
390
+ name: field.name,
391
+ type: field.type,
392
+ required: field.required ?? false,
393
+ list: spec.list ?? false,
394
+ kind: spec.type.kind,
395
+ ...spec.type.kind === "scalar" ? { scalar: spec.type.name } : {},
396
+ ...spec.type.kind === "ref" ? { target: spec.type.target } : {},
397
+ ...spec.type.kind === "block" && blockTypes ? { blockTypes } : {}
398
+ }];
399
+ })
400
+ })) };
401
+ })
402
+ };
403
+ /** A `blocks` field names its allowed types by id; the SDK needs their names. */
404
+ function blockTypeNames(context, settings) {
405
+ const types = settings?.types;
406
+ if (!Array.isArray(types)) return void 0;
407
+ return types.filter((id) => typeof id === "string").map((id) => context.manablox.contentTypes.tryGet(id)?.name).filter((name) => Boolean(name));
408
+ }
409
+ function collectMenuRows(items) {
410
+ const out = [];
411
+ for (const item of items) {
412
+ if (item.content) out.push(item.content);
413
+ out.push(...collectMenuRows(item.children));
414
+ }
415
+ return out;
416
+ }
417
+ async function resolvePermalink(path, input, context) {
418
+ const permalink = path.replace(/^\/+|\/+$/g, "");
419
+ const row = await context.repos.content.findByPermalink(context.spaceId, input.locale ?? context.locale, permalink, true);
420
+ if (!row) throw ManabloxError.notFound("content.notFound", { permalink });
421
+ return serializeContent(row, context, parseExpand(input.expand));
422
+ }
423
+ //#endregion
424
+ export { parseExpand, publicRouter, serializeAsset, serializeContent, serializeContents };
package/package.json CHANGED
@@ -1,31 +1,38 @@
1
1
  {
2
2
  "name": "@manablox/api-public",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
7
- "types": "./src/index.ts",
8
- "default": "./src/index.ts"
7
+ "types": "./dist/index.d.ts",
8
+ "default": "./dist/index.js"
9
9
  }
10
10
  },
11
- "main": "./src/index.ts",
12
- "types": "./src/index.ts",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
13
  "dependencies": {
14
- "@manablox/core": "0.2.0",
15
- "@manablox/db": "0.2.0",
16
- "@manablox/media": "0.2.0",
17
- "@manablox/services": "0.2.0",
14
+ "@manablox/core": "0.3.0",
15
+ "@manablox/db": "0.3.0",
16
+ "@manablox/media": "0.3.0",
17
+ "@manablox/services": "0.3.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.2.0",
23
+ "@manablox/fields": "0.3.0",
24
24
  "@types/node": "^26.4.1",
25
+ "tsdown": "^0.23.0",
25
26
  "typescript": "^7.0.2",
26
27
  "vitest": "^5.0.0"
27
28
  },
29
+ "files": [
30
+ "dist",
31
+ "!dist/**/*.map",
32
+ "README.md"
33
+ ],
28
34
  "scripts": {
35
+ "build": "tsdown",
29
36
  "typecheck": "tsc --noEmit",
30
37
  "test": "vitest run"
31
38
  }
package/src/context.ts DELETED
@@ -1,23 +0,0 @@
1
- import type { Manablox } from '@manablox/core';
2
- import type { Repositories } from '@manablox/db';
3
- import type { MediaService } from '@manablox/media';
4
- import type { Loaders, MenuService } from '@manablox/services';
5
-
6
- /**
7
- * The delivery context.
8
- *
9
- * Compare `RpcContext`: no `auth`, no `apiKeys`, no `principal`, no `content` service.
10
- * A procedure here cannot authenticate a caller or write, because the context it runs
11
- * in has nothing to do it with — the read-only guarantee is a property of the type, not
12
- * a rule contributors have to remember.
13
- */
14
- export interface PublicContext {
15
- manablox: Manablox;
16
- repos: Repositories;
17
- media: MediaService;
18
- menus: MenuService;
19
- loaders: Loaders;
20
- /** The one space this instance serves. Never taken from the request. */
21
- spaceId: string;
22
- locale: string;
23
- }
package/src/index.ts DELETED
@@ -1,3 +0,0 @@
1
- export * from './context.js';
2
- export * from './router.js';
3
- export * from './serialize.js';