@xndrjs/contentful-to-zod 0.1.0-alpha.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,207 @@
1
+ # @xndrjs/contentful-to-zod
2
+
3
+ Generate **Zod 4** schemas from Contentful content types (CMA). Stop hand-writing codegen and get precise `z.infer` types where graphql-codegen stays on `string`.
4
+
5
+ This package outputs **Zod schemas and optional locale helpers only** — no `domain.shape` in the generated file. If you use [xndrjs](https://github.com/xndrjs/toolkit), wire schemas with `zodToValidator` from [`@xndrjs/domain-zod`](../domain-zod) in your own code.
6
+
7
+ ## Principles
8
+
9
+ - **1:1 mapping** from the CMA content model to Zod (field type + `validations` + `required`).
10
+ - **Two schema shapes** (configurable): **flat / CMA** (single value per field) and **delivery** (`localized: true` → `z.record(ContentfulLocaleCodeSchema, T)`).
11
+ - **Default `locale.mode: "both"`** — each content type exports flat + delivery schemas (e.g. `BlogPostSchema` + `BlogPostDeliverySchema`) plus `flatten*` helpers when both are emitted.
12
+ - **Locales from your space** — enum and constants are generated from a CMA `/locales` snapshot; `CONTENTFUL_DEFAULT_LOCALE` is only the default parameter for helpers (no runtime rule that the default locale must exist in every record).
13
+ - **Self-contained output** — generated file depends only on `zod`; shared primitives (entry/asset links, location, …) are inlined once at the top.
14
+ - **Optional Object overrides** — CMA declares `Object` without inner shape; supply Zod schemas via config for `{contentTypeId}.{fieldId}` keys.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pnpm add @xndrjs/contentful-to-zod zod@^4
20
+ ```
21
+
22
+ ## CLI
23
+
24
+ ```bash
25
+ contentful-to-zod \
26
+ --space-id $SPACE \
27
+ --environment master \
28
+ --management-token $TOKEN \
29
+ --out ./src/generated/contentful.schemas.ts \
30
+ --snapshot ./src/generated/content-types.json \
31
+ --snapshot-locales ./src/generated/locales.json
32
+ ```
33
+
34
+ Offline / CI (no CMA calls):
35
+
36
+ ```bash
37
+ contentful-to-zod \
38
+ --from-snapshot \
39
+ --snapshot ./src/generated/content-types.json \
40
+ --snapshot-locales ./src/generated/locales.json \
41
+ --out ./src/generated/contentful.schemas.ts
42
+ ```
43
+
44
+ Other flags: `--content-types blogPost,author`, `--config ./contentful-to-zod.config.ts`, `--dry-run` (print to stdout).
45
+
46
+ Environment fallbacks: `CONTENTFUL_MANAGEMENT_TOKEN`, `CONTENTFUL_SPACE_ID`, `CONTENTFUL_ENVIRONMENT`.
47
+
48
+ `--snapshot-locales` is required when using `--from-snapshot` and locale mode is `delivery` or `both` (the default).
49
+
50
+ ## Programmatic API
51
+
52
+ ```ts
53
+ import { fetchContentTypes, fetchLocales, generateZodSchemas } from "@xndrjs/contentful-to-zod";
54
+ import { writeFile } from "node:fs/promises";
55
+
56
+ const cma = { spaceId, accessToken, environmentId: "master" };
57
+
58
+ const [contentTypes, locales] = await Promise.all([fetchContentTypes(cma), fetchLocales(cma)]);
59
+
60
+ const source = generateZodSchemas(contentTypes, {
61
+ locales,
62
+ config: { locale: { mode: "both" } },
63
+ });
64
+
65
+ await writeFile("./src/generated/contentful.schemas.ts", source, "utf8");
66
+ ```
67
+
68
+ `generateZodSchemas` options: `contentTypeIds`, `locales` (required when mode is `delivery` or `both`), `localeMode`, `config`.
69
+
70
+ ## Locale mode
71
+
72
+ In `contentful-to-zod.config.ts` (or `generateZodSchemas` options):
73
+
74
+ ```ts
75
+ import { defineConfig } from "@xndrjs/contentful-to-zod";
76
+
77
+ export default defineConfig({
78
+ locale: {
79
+ /** Default: "both" */
80
+ mode: "both", // "cma" | "delivery" | "both"
81
+ },
82
+ });
83
+ ```
84
+
85
+ | `locale.mode` | Generated exports |
86
+ | ------------------ | ----------------------------------------------------------------------- |
87
+ | `"cma"` | Flat schemas only (`BlogPostSchema`, `BlogPostFields`) |
88
+ | `"delivery"` | Delivery schemas + `pickLocale` + locale enum/constants |
89
+ | `"both"` (default) | Flat + delivery + `pickLocale` + `flatten{Type}Fields` per content type |
90
+
91
+ Rules:
92
+
93
+ - **Flat and delivery** field schemas are **nullable** (Preview/draft and `pickLocale` can yield `null`; optional CMA fields also `.optional()`).
94
+ - **`localized: true`** — flat uses `T`; delivery uses `z.record(ContentfulLocaleCodeSchema, T)` (same nullability rules on the outer field).
95
+ - **`disabled` / `omitted`** fields are still included (full blueprint).
96
+
97
+ ### Generated locale primitives
98
+
99
+ When delivery or both mode is active, the file starts with:
100
+
101
+ ```ts
102
+ /** @generated from space locales snapshot */
103
+ export const ContentfulLocaleCodeSchema = z.enum(["en-US", "it-IT"]);
104
+ export type ContentfulLocaleCode = z.infer<typeof ContentfulLocaleCodeSchema>;
105
+
106
+ export const CONTENTFUL_LOCALE_CODES = ContentfulLocaleCodeSchema.options;
107
+ export const CONTENTFUL_DEFAULT_LOCALE = "en-US" as const;
108
+ ```
109
+
110
+ ### Flat vs delivery example
111
+
112
+ ```ts
113
+ // flat / CMA — single value per field (nullable for pickLocale / flatten)
114
+ export const BlogPostSchema = z.object({
115
+ title: z.string().max(256).nullable(),
116
+ slug: z.string().nullable(),
117
+ author: ContentfulEntryLinkSchema.nullable().optional(),
118
+ });
119
+
120
+ export type BlogPostFields = z.infer<typeof BlogPostSchema>;
121
+
122
+ // delivery — REST/Preview (all fields nullable; optional CMA fields also .optional())
123
+ export const BlogPostDeliverySchema = z.object({
124
+ title: z.record(ContentfulLocaleCodeSchema, z.string().max(256)).nullable(),
125
+ slug: z.string().nullable(),
126
+ author: ContentfulEntryLinkSchema.nullable().optional(),
127
+ });
128
+
129
+ export type BlogPostDeliveryFields = z.infer<typeof BlogPostDeliverySchema>;
130
+ ```
131
+
132
+ ## Generated helpers
133
+
134
+ Helpers are pure functions in the same output file. They **do not validate** — parse after flattening:
135
+
136
+ ```ts
137
+ import { BlogPostSchema, flattenBlogPostFields, pickLocale } from "./generated/contentful.schemas";
138
+
139
+ const flat = flattenBlogPostFields(deliveryFields, "it-IT");
140
+ const post = BlogPostSchema.parse(flat);
141
+ ```
142
+
143
+ - **`pickLocale`** — read one locale from a localized delivery field (`Record<ContentfulLocaleCode, T> | null`); missing locale or `null` input → `null`. Default locale parameter is `CONTENTFUL_DEFAULT_LOCALE`.
144
+ - **`flatten{ContentType}Fields`** — map `*DeliveryFields` → flat `*Fields` for one locale (one per content type when `mode` is `both`). Passes `null` through for absent localized values.
145
+
146
+ There is no runtime dependency on `@xndrjs/contentful-to-zod` in production — only the generated file and `zod`.
147
+
148
+ ## Object field overrides
149
+
150
+ ```ts
151
+ // contentful-to-zod.config.ts
152
+ import { z } from "zod";
153
+ import { defineConfig } from "@xndrjs/contentful-to-zod";
154
+
155
+ export default defineConfig({
156
+ objects: {
157
+ "blogPost.metadata": z.object({
158
+ seoTitle: z.string(),
159
+ noIndex: z.boolean().optional(),
160
+ }),
161
+ },
162
+ });
163
+ ```
164
+
165
+ Overrides apply to the **base field type** `T`. In delivery mode, localized fields wrap `z.record(ContentfulLocaleCodeSchema, T).nullable()` around that base (plus `.optional()` when applicable).
166
+
167
+ Overrides are inlined at codegen time — the config is not imported at runtime.
168
+
169
+ ## Mapping Delivery / REST data
170
+
171
+ 1. Parse or type raw `fields` as `*DeliveryFields` (or use `flatten*` when `mode` is `both`).
172
+ 2. Validate the flat shape with `*Schema.parse(...)`.
173
+
174
+ Entry/asset link objects and CMA validations (size, range, regex, etc.) are reflected in the generated Zod chains.
175
+
176
+ ## xndrjs recipe (optional)
177
+
178
+ Wire flat field schemas and the locale enum into `@xndrjs/domain-zod`:
179
+
180
+ ```ts
181
+ import { domain, zodToValidator } from "@xndrjs/domain-zod";
182
+ import { BlogPostSchema, ContentfulLocaleCodeSchema } from "./generated/contentful.schemas";
183
+
184
+ export const BlogPost = domain.shape("BlogPost", zodToValidator(BlogPostSchema));
185
+
186
+ export const SupportedLocale = domain.primitive(
187
+ "SupportedLocale",
188
+ zodToValidator(ContentfulLocaleCodeSchema)
189
+ );
190
+ ```
191
+
192
+ Use `SupportedLocale` (or your own name) wherever application code should accept only locales known to the space snapshot.
193
+
194
+ ## CMA field mapping (summary)
195
+
196
+ | CMA `type` | Zod base |
197
+ | ------------ | ------------------------------------------------------ |
198
+ | Symbol, Text | `z.string()` + validations |
199
+ | Integer | `z.number().int()` |
200
+ | Number | `z.number()` |
201
+ | Boolean | `z.boolean()` |
202
+ | Date | `z.string()` / `z.iso.datetime()` |
203
+ | Location | `z.object({ lat, lon })` |
204
+ | Object | `z.record(z.string(), z.unknown())` or config override |
205
+ | Link | Contentful link object |
206
+ | Array | `z.array(itemSchema)` |
207
+ | Rich Text | `z.looseObject({ nodeType: z.literal("document") })` |