@lemonadecms/client 0.0.0-stage → 0.1.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,397 @@
1
+ import { generateTypes } from '../generate-types.mjs';
2
+ import { record, uuid } from './config.mjs';
3
+ import { fail } from './errors.mjs';
4
+ const identifier = /^[a-z][a-z0-9_]{0,47}$/;
5
+ const apiKey = (value) => typeof value === 'string' && identifier.test(value);
6
+ const language = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$/;
7
+ const own = (value, keys) =>
8
+ record(value) && Object.keys(value).every((key) => keys.includes(key));
9
+ const unique = (values) => new Set(values).size === values.length;
10
+ const segmenter = new Intl.Segmenter('und', { granularity: 'grapheme' });
11
+ function string(value, max) {
12
+ if (typeof value !== 'string' || !value.trim()) return false;
13
+ let count = 0;
14
+ for (const _ of segmenter.segment(value)) if (++count > max) return false;
15
+ return true;
16
+ }
17
+ function fieldGuidance(field) {
18
+ if (Object.hasOwn(field, 'help_text')) {
19
+ if (typeof field.help_text !== 'string') invalid();
20
+ let count = 0;
21
+ for (const _ of segmenter.segment(field.help_text)) if (++count > 500) invalid();
22
+ }
23
+ if (!Object.hasOwn(field, 'validation')) return;
24
+ const rule = field.validation;
25
+ const text = ['text', 'long_text', 'markdown'].includes(field.type);
26
+ const allowed = field.type === 'text'
27
+ ? ['min_length', 'max_length', 'format']
28
+ : text ? ['min_length', 'max_length'] : field.type === 'number' ? ['min', 'max']
29
+ : field.type === 'choices' ? ['min_items', 'max_items'] : [];
30
+ if (!own(rule, allowed) || Object.keys(rule).length === 0) invalid();
31
+ if (text) {
32
+ const ceiling = field.type === 'text' ? 500 : 20000;
33
+ for (const key of ['min_length', 'max_length'])
34
+ if (Object.hasOwn(rule, key) &&
35
+ (!Number.isInteger(rule[key]) || rule[key] < 0 || rule[key] > ceiling)) invalid();
36
+ if (Object.hasOwn(rule, 'min_length') && Object.hasOwn(rule, 'max_length') &&
37
+ rule.min_length > rule.max_length) invalid();
38
+ if (Object.hasOwn(rule, 'format') && !['email', 'url'].includes(rule.format)) invalid();
39
+ } else if (field.type === 'number') {
40
+ for (const key of ['min', 'max'])
41
+ if (Object.hasOwn(rule, key) && (typeof rule[key] !== 'number' || !Number.isFinite(rule[key]))) invalid();
42
+ if (Object.hasOwn(rule, 'min') && Object.hasOwn(rule, 'max') && rule.min > rule.max) invalid();
43
+ } else if (field.type === 'choices') {
44
+ for (const key of ['min_items', 'max_items'])
45
+ if (Object.hasOwn(rule, key) &&
46
+ (!Number.isInteger(rule[key]) || rule[key] < 0 || rule[key] > 100)) invalid();
47
+ if (Object.hasOwn(rule, 'min_items') && Object.hasOwn(rule, 'max_items') &&
48
+ rule.min_items > rule.max_items) invalid();
49
+ }
50
+ }
51
+ const invalid = () =>
52
+ fail(5, 'Invalid or unsupported schema export. No output was written.');
53
+ const leaves = [
54
+ 'text',
55
+ 'long_text',
56
+ 'markdown',
57
+ 'number',
58
+ 'boolean',
59
+ 'color',
60
+ 'date',
61
+ 'datetime',
62
+ 'choice',
63
+ 'choices',
64
+ 'reference',
65
+ 'references',
66
+ 'image',
67
+ 'file',
68
+ 'rich_text',
69
+ ];
70
+ const composed = ['component', 'components', 'blocks'];
71
+ function bounded(value) {
72
+ const stack = [[value, 0]];
73
+ let nodes = 0;
74
+ while (stack.length) {
75
+ const [item, depth] = stack.pop();
76
+ if (++nodes > 150000 || depth > 16) invalid();
77
+ if (
78
+ typeof item === 'string' &&
79
+ /(?:pk|sk)_[A-Za-z0-9_-]{43}|lmd_(?:pk|sk|mgmt)_[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}/.test(
80
+ item,
81
+ )
82
+ )
83
+ fail(5, 'Schema contains a credential-shaped value. No output was written.');
84
+ if (item && typeof item === 'object')
85
+ for (const child of Object.values(item)) stack.push([child, depth + 1]);
86
+ }
87
+ }
88
+ function fields(values, keys, component = false) {
89
+ if (
90
+ !Array.isArray(values) ||
91
+ values.length > 24 ||
92
+ !unique(values.map((field) => field?.key))
93
+ )
94
+ invalid();
95
+ for (const field of values) {
96
+ if (
97
+ !record(field) ||
98
+ !apiKey(field.key) ||
99
+ !string(field.label, 80) ||
100
+ typeof field.required !== 'boolean' ||
101
+ ![...leaves, ...composed].includes(field.type)
102
+ )
103
+ invalid();
104
+ const extra =
105
+ ['choice', 'choices'].includes(field.type)
106
+ ? ['options']
107
+ : ['reference', 'references'].includes(field.type)
108
+ ? ['target']
109
+ : field.type === 'rich_text'
110
+ ? ['images', 'targets']
111
+ : composed.includes(field.type)
112
+ ? ['components']
113
+ : [];
114
+ const defaults = [
115
+ 'text',
116
+ 'long_text',
117
+ 'markdown',
118
+ 'number',
119
+ 'boolean',
120
+ 'color',
121
+ 'date',
122
+ 'datetime',
123
+ 'choice',
124
+ 'choices',
125
+ ];
126
+ const seo = ['text', 'long_text', 'image'].includes(field.type) ? ['seo'] : [];
127
+ if (
128
+ !own(field, [
129
+ 'key',
130
+ 'label',
131
+ 'type',
132
+ 'required',
133
+ 'localized',
134
+ 'show_if',
135
+ 'help_text',
136
+ 'validation',
137
+ ...(!component ? ['group'] : []),
138
+ ...extra,
139
+ ...seo,
140
+ ...(field.type === 'text' && !component ? ['slug'] : []),
141
+ ...(defaults.includes(field.type) ? ['default'] : []),
142
+ ])
143
+ )
144
+ invalid();
145
+ if (Object.hasOwn(field, 'group') &&
146
+ (typeof field.group !== 'string' || !field.group ||
147
+ /^\p{White_Space}|\p{White_Space}$/u.test(field.group) || Buffer.byteLength(field.group) > 80 ||
148
+ /[\u0000-\u001f\u007f-\u009f\u2028\u2029\ud800-\udfff]/u.test(field.group)))
149
+ invalid();
150
+ fieldGuidance(field);
151
+ if (
152
+ Object.hasOwn(field, 'localized') &&
153
+ (typeof field.localized !== 'boolean' ||
154
+ (field.localized &&
155
+ !['text', 'long_text', 'markdown', 'rich_text'].includes(field.type)))
156
+ )
157
+ invalid();
158
+ if (component && (composed.includes(field.type) || field.localized === true))
159
+ invalid();
160
+ if (Object.hasOwn(field, 'default')) {
161
+ const value = field.default;
162
+ if (field.type === 'choices' && value === null) invalid();
163
+ if (
164
+ value !== null &&
165
+ (field.type === 'choices'
166
+ ? !Array.isArray(value) || value.length > 100 || !unique(value) ||
167
+ !value.every((option) => typeof option === 'string' && field.options?.includes(option))
168
+ : field.type === 'boolean'
169
+ ? typeof value !== 'boolean'
170
+ : field.type === 'number'
171
+ ? typeof value !== 'number' || !Number.isFinite(value)
172
+ : typeof value !== 'string')
173
+ )
174
+ invalid();
175
+ if (field.type === 'choice' && value !== null && !field.options?.includes(value))
176
+ invalid();
177
+ }
178
+ if (
179
+ ['choice', 'choices'].includes(field.type) &&
180
+ (!Array.isArray(field.options) ||
181
+ field.options.length < 1 ||
182
+ field.options.length > 100 ||
183
+ !unique(field.options) ||
184
+ !field.options.every((value) => string(value, 100)))
185
+ )
186
+ invalid();
187
+ if (['reference', 'references'].includes(field.type) && !keys.has(field.target))
188
+ invalid();
189
+ if (
190
+ field.type === 'rich_text' &&
191
+ ((Object.hasOwn(field, 'images') && typeof field.images !== 'boolean') ||
192
+ !Array.isArray(field.targets || []) ||
193
+ (field.targets || []).length > 12 ||
194
+ !unique(field.targets || []) ||
195
+ !(field.targets || []).every((target) => keys.has(target)))
196
+ )
197
+ invalid();
198
+ if (composed.includes(field.type)) {
199
+ if (
200
+ !Array.isArray(field.components) ||
201
+ field.components.length < 1 ||
202
+ field.components.length > 12 ||
203
+ (field.type !== 'blocks' && field.components.length !== 1) ||
204
+ !unique(field.components.map((item) => item?.key))
205
+ )
206
+ invalid();
207
+ for (const definition of field.components) {
208
+ if (
209
+ !own(definition, ['key', 'name', 'fields']) ||
210
+ !apiKey(definition.key) ||
211
+ !string(definition.name, 80) ||
212
+ !definition.fields?.length
213
+ )
214
+ invalid();
215
+ fields(definition.fields, keys, true);
216
+ }
217
+ }
218
+ }
219
+ const byKey = new Map(values.map((field) => [field.key, field]));
220
+ for (const field of values) {
221
+ if (Object.hasOwn(field, 'slug')) {
222
+ const rule = field.slug;
223
+ const source = byKey.get(rule?.source);
224
+ if (
225
+ !own(rule, ['source', 'unique']) ||
226
+ Object.keys(rule).length !== 2 ||
227
+ typeof rule.unique !== 'boolean' ||
228
+ !apiKey(rule.source) ||
229
+ !source ||
230
+ source === field ||
231
+ source.type !== 'text'
232
+ )
233
+ invalid();
234
+ }
235
+ if (!Object.hasOwn(field, 'show_if')) continue;
236
+ const rule = field.show_if;
237
+ const controller = byKey.get(rule?.field);
238
+ if (!own(rule, ['field', 'equals']) || Object.keys(rule).length !== 2 ||
239
+ !controller || controller === field || controller.localized === true ||
240
+ (controller.type === 'boolean' ? typeof rule.equals !== 'boolean' :
241
+ controller.type !== 'choice' || !controller.options.includes(rule.equals))) invalid();
242
+ const visited = new Set([field.key]);
243
+ let current = controller;
244
+ while (current) {
245
+ if (visited.has(current.key)) invalid();
246
+ visited.add(current.key);
247
+ current = byKey.get(current.show_if?.field);
248
+ }
249
+ }
250
+ }
251
+ export function validateSchema(schema) {
252
+ bounded(schema);
253
+ if (
254
+ !own(schema, [
255
+ 'format_version',
256
+ 'project_id',
257
+ 'schema_revision',
258
+ 'locales',
259
+ 'default_locale',
260
+ 'models',
261
+ 'environment',
262
+ ]) ||
263
+ schema.format_version !== 1 ||
264
+ !uuid.test(schema.project_id || '') ||
265
+ !Number.isSafeInteger(schema.schema_revision) ||
266
+ schema.schema_revision < 0 ||
267
+ !Array.isArray(schema.locales) ||
268
+ schema.locales.length < 1 ||
269
+ schema.locales.length > 20 ||
270
+ !unique(schema.locales) ||
271
+ !schema.locales.every(
272
+ (locale) => typeof locale === 'string' && language.test(locale),
273
+ ) ||
274
+ !(
275
+ schema.default_locale === null || schema.locales.includes(schema.default_locale)
276
+ ) ||
277
+ !Array.isArray(schema.models) ||
278
+ schema.models.length > 256 ||
279
+ !unique(schema.models.map((model) => model?.key))
280
+ )
281
+ invalid();
282
+ if (
283
+ schema.environment &&
284
+ (!own(schema.environment, ['id', 'key']) ||
285
+ !uuid.test(schema.environment.id || '') ||
286
+ typeof schema.environment.key !== 'string' ||
287
+ !/^[a-z][a-z0-9_-]{0,31}$/.test(schema.environment.key) ||
288
+ schema.environment.id === schema.project_id)
289
+ )
290
+ invalid();
291
+ if (Object.hasOwn(schema, 'environment') && !schema.environment) invalid();
292
+ const keys = new Set(schema.models.map((model) => model?.key));
293
+ for (const model of schema.models) {
294
+ if (
295
+ !own(model, ['key', 'name', 'fields', 'remote', 'singleton']) ||
296
+ (Object.hasOwn(model, 'singleton') && typeof model.singleton !== 'boolean') ||
297
+ !apiKey(model.key) ||
298
+ !string(model.name, 80)
299
+ )
300
+ invalid();
301
+ fields(model.fields, keys);
302
+ if (Object.hasOwn(model, 'remote')) {
303
+ if (
304
+ !own(model.remote, ['version', 'fields']) ||
305
+ !Number.isSafeInteger(model.remote.version) ||
306
+ model.remote.version < 1 ||
307
+ !Array.isArray(model.remote.fields) ||
308
+ model.remote.fields.length < 1 ||
309
+ model.remote.fields.length > 8 ||
310
+ !unique(model.remote.fields.map((field) => field?.key)) ||
311
+ !model.remote.fields.every(
312
+ (field) =>
313
+ own(field, ['key', 'type']) &&
314
+ apiKey(field.key) &&
315
+ ['text', 'number', 'boolean'].includes(field.type),
316
+ )
317
+ )
318
+ invalid();
319
+ }
320
+ }
321
+ // One type generator remains authoritative for delivery types, references and SEO roles.
322
+ try {
323
+ generateTypes(schema);
324
+ } catch {
325
+ invalid();
326
+ }
327
+ return schema;
328
+ }
329
+ export function canonical(value) {
330
+ if (Array.isArray(value)) return value.map(canonical);
331
+ if (record(value))
332
+ return Object.fromEntries(
333
+ Object.keys(value)
334
+ .sort()
335
+ .map((key) => [key, canonical(value[key])]),
336
+ );
337
+ return value;
338
+ }
339
+ export function serialize(schema) {
340
+ return (
341
+ JSON.stringify(
342
+ canonical({
343
+ ...schema,
344
+ models: [...schema.models].sort((a, b) =>
345
+ a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
346
+ ),
347
+ }),
348
+ null,
349
+ 2,
350
+ ) + '\n'
351
+ );
352
+ }
353
+ export function schemaIdentity(schema) {
354
+ return {
355
+ workspace: schema.project_id,
356
+ environment: schema.environment?.key || 'production',
357
+ environmentId: schema.environment?.id || schema.project_id,
358
+ revision: schema.schema_revision,
359
+ models: schema.models.length,
360
+ fields: schema.models.reduce((total, model) => total + model.fields.length, 0),
361
+ };
362
+ }
363
+ export function compareSchemas(baseline, current) {
364
+ const changes = [];
365
+ const equal = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
366
+ function compare(left, right, path) {
367
+ if (equal(left, right)) return;
368
+ if (record(left) && record(right)) {
369
+ for (const key of [
370
+ ...new Set([...Object.keys(left), ...Object.keys(right)]),
371
+ ].sort())
372
+ compare(left[key], right[key], `${path}.${key}`);
373
+ } else
374
+ changes.push({
375
+ path,
376
+ change:
377
+ left === undefined ? 'added' : right === undefined ? 'removed' : 'changed',
378
+ });
379
+ }
380
+ compare(baseline.locales, current.locales, 'locales');
381
+ compare(baseline.default_locale, current.default_locale, 'default_locale');
382
+ const byKey = (rows) => Object.fromEntries(rows.map((row) => [row.key, row]));
383
+ // Workspace/environment IDs and revision counters intentionally differ between targets.
384
+ const modelMap = (schema) =>
385
+ Object.fromEntries(
386
+ schema.models.map((model) => [
387
+ model.key,
388
+ {
389
+ ...model,
390
+ fields: byKey(model.fields),
391
+ field_order: model.fields.map((field) => field.key),
392
+ },
393
+ ]),
394
+ );
395
+ compare(modelMap(baseline), modelMap(current), 'models');
396
+ return changes;
397
+ }
@@ -0,0 +1,180 @@
1
+ import { definitions } from './definitions.mjs';
2
+ const quote = (value) =>
3
+ JSON.stringify(value).replace(
4
+ /[\u0085\u2028\u2029]/g,
5
+ (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`,
6
+ );
7
+ const property = (wire, type, name, initial = '') =>
8
+ ` [JsonPropertyName(${quote(wire)})]\n public ${type} ${name} { get; init; }${initial}\n`;
9
+
10
+ export function generateCSharp(schema) {
11
+ const { records, choices, names } = definitions(schema);
12
+ function type(field) {
13
+ switch (field.type) {
14
+ case 'number':
15
+ return 'double';
16
+ case 'boolean':
17
+ return 'bool';
18
+ case 'choice':
19
+ return field.name;
20
+ case 'choices':
21
+ return `List<${field.name}>`;
22
+ case 'reference':
23
+ return `ContentReference<${names.get(field.target)}>`;
24
+ case 'references':
25
+ return `List<ContentReference<${names.get(field.target)}>>`;
26
+ case 'image':
27
+ case 'file':
28
+ return 'ContentAsset';
29
+ case 'rich_text':
30
+ return 'ContentRichText';
31
+ case 'component':
32
+ return field.variants[0].name;
33
+ case 'components':
34
+ return `List<${field.variants[0].name}>`;
35
+ case 'blocks':
36
+ return `List<${field.name}>`;
37
+ default:
38
+ return 'string';
39
+ }
40
+ }
41
+ const blockFields = records.flatMap((record) =>
42
+ record.fields.filter((field) => field.type === 'blocks'),
43
+ );
44
+ const blockNames = new Set(blockFields.map((field) => field.name));
45
+ const blocks = blockFields
46
+ .map((field) => {
47
+ const cases = field.variants
48
+ .map(
49
+ (v) =>
50
+ ` ${quote(v.key)} => JsonSerializer.Deserialize<${v.name}>(root.GetRawText(), options)!,`,
51
+ )
52
+ .join('\n');
53
+ const writes = field.variants
54
+ .map(
55
+ (v) =>
56
+ ` case ${v.name} item: JsonSerializer.Serialize(writer, item, options); break;`,
57
+ )
58
+ .join('\n');
59
+ // The wire discriminator can occur anywhere in a JSON object. Built-in .NET8
60
+ // metadata polymorphism requires it first; this converter does not reorder
61
+ // or change the delivery contract and rejects unknown variants explicitly.
62
+ return `[JsonConverter(typeof(Converter_${field.name}))]
63
+ public abstract record ${field.name};
64
+ public sealed class Converter_${field.name} : JsonConverter<${field.name}>
65
+ {
66
+ public override ${field.name} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
67
+ {
68
+ using var document = JsonDocument.ParseValue(ref reader);
69
+ var root = document.RootElement;
70
+ if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("type", out var kind) || kind.ValueKind != JsonValueKind.String) throw new JsonException("Missing component discriminator.");
71
+ return kind.GetString() switch
72
+ {
73
+ ${cases}
74
+ _ => throw new JsonException("Unknown component discriminator.")
75
+ };
76
+ }
77
+ public override void Write(Utf8JsonWriter writer, ${field.name} value, JsonSerializerOptions options)
78
+ {
79
+ switch (value)
80
+ {
81
+ ${writes}
82
+ default: throw new JsonException("Unknown component variant.");
83
+ }
84
+ }
85
+ }
86
+ `;
87
+ })
88
+ .join('\n');
89
+ const choiceTypes = choices
90
+ .map(
91
+ (field) => `[JsonConverter(typeof(Converter_${field.name}))]
92
+ public enum ${field.name} { ${field.options.map((_, i) => `Value${i}`).join(', ')} }
93
+ public sealed class Converter_${field.name} : StringChoiceConverter<${field.name}>
94
+ {
95
+ private static readonly string[] Items = { ${field.options.map(quote).join(', ')} };
96
+ protected override string[] Values => Items;
97
+ }
98
+ `,
99
+ )
100
+ .join('\n');
101
+ const objects = records
102
+ .map((record) => {
103
+ const props = record.fields
104
+ .map((field) => property(field.key, `${type(field)}?`, `Field_${field.key}`))
105
+ .join('');
106
+ if (!record.component)
107
+ return `public sealed record ${record.name}\n{\n${props}}\n`;
108
+ const isBlock = blockNames.has(record.parent);
109
+ return `public sealed record ${record.name}${isBlock ? ` : ${record.parent}` : ''}
110
+ {
111
+ ${property('id', 'string', 'Id', ' = "";')} [JsonPropertyName("type")]
112
+ public string Type => ${quote(record.key)};
113
+ ${property('fields', `${record.name}Fields`, 'Fields', ' = new();')}}
114
+ public sealed record ${record.name}Fields
115
+ {
116
+ ${props}}
117
+ `;
118
+ })
119
+ .join('\n');
120
+ return `// Generated by Lemonade CMS. Do not edit. .NET8+; System.Text.Json only.
121
+ // Every model field allows historical absence/null; previews may be incomplete.
122
+ // DTOs are transport contracts, not full content/schema validation.
123
+ #nullable enable
124
+ using System;
125
+ using System.Collections.Generic;
126
+ using System.Text.Json;
127
+ using System.Text.Json.Serialization;
128
+
129
+ namespace Lemonade.Content;
130
+
131
+ public sealed record ContentReference<TFields>
132
+ {
133
+ ${property('id', 'string', 'Id', ' = "";')}${property('fields', 'TFields?', 'Fields')}${property('published_at', 'string?', 'PublishedAt')}}
134
+ public sealed record ContentRendition
135
+ {
136
+ ${property('key', 'string', 'Key', ' = "";')}${property('preset', 'string', 'Preset', ' = "";')}${property('width', 'int', 'Width')}${property('height', 'int', 'Height')}${property('content_type', 'string', 'ContentType', ' = "";')}${property('url', 'string', 'Url', ' = "";')}}
137
+ public sealed record FocalPoint
138
+ {
139
+ ${property('x', 'double', 'X')}${property('y', 'double', 'Y')}}
140
+ public sealed record AssetMetadata
141
+ {
142
+ ${property('title', 'string?', 'Title')}${property('alt', 'string?', 'Alt')}${property('description', 'string?', 'Description')}${property('focal_point', 'FocalPoint?', 'FocalPoint')}}
143
+ public sealed record ContentAsset
144
+ {
145
+ ${property('id', 'string', 'Id', ' = "";')}${property('url', 'string', 'Url', ' = "";')}${property('content_type', 'string', 'ContentType', ' = "";')}${property('byte_size', 'long', 'ByteSize')}${property('metadata', 'AssetMetadata', 'Metadata', ' = new();')}${property('renditions', 'List<ContentRendition>?', 'Renditions')}}
146
+ // Preserve versioned rich-text JSON. CMS node/target validation stays authoritative.
147
+ public sealed record ContentRichText
148
+ {
149
+ ${property('version', 'int', 'Version')}${property('document', 'JsonElement', 'Document')}}
150
+ public sealed record ContentEntry<TFields>
151
+ {
152
+ ${property('id', 'string', 'Id', ' = "";')}${property('fields', 'TFields?', 'Fields')}${property('published_at', 'string?', 'PublishedAt')}${property('remote', 'JsonElement?', 'Remote')}}
153
+ public sealed record PageMeta
154
+ {
155
+ ${property('next_cursor', 'string?', 'NextCursor')}${property('limit', 'int', 'Limit')}${property('page', 'int?', 'Page')}${property('page_size', 'int?', 'PageSize')}${property('previous_cursor', 'string?', 'PreviousCursor')}${property('has_next_page', 'bool?', 'HasNextPage')}${property('has_previous_page', 'bool?', 'HasPreviousPage')}}
156
+ public sealed record ContentPage<TFields>
157
+ {
158
+ ${property('data', 'List<ContentEntry<TFields>>', 'Data', ' = new();')}${property('meta', 'PageMeta?', 'Meta')}}
159
+ public abstract class StringChoiceConverter<T> : JsonConverter<T> where T : struct, Enum
160
+ {
161
+ protected abstract string[] Values { get; }
162
+ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
163
+ {
164
+ if (reader.TokenType != JsonTokenType.String) throw new JsonException("Expected string choice.");
165
+ var index = Array.IndexOf(Values, reader.GetString());
166
+ if (index < 0) throw new JsonException("Unknown choice.");
167
+ return (T)Enum.ToObject(typeof(T), index);
168
+ }
169
+ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
170
+ {
171
+ var index = Convert.ToInt32(value);
172
+ if (index < 0 || index >= Values.Length) throw new JsonException("Unknown choice.");
173
+ writer.WriteStringValue(Values[index]);
174
+ }
175
+ }
176
+
177
+ ${choiceTypes}
178
+ ${blocks}
179
+ ${objects}`;
180
+ }
@@ -0,0 +1,52 @@
1
+ export const languages = Object.freeze(['typescript', 'csharp', 'python']);
2
+
3
+ export function definitions(schema) {
4
+ const used = new Set();
5
+ function symbol(base) {
6
+ let name = base;
7
+ for (let suffix = 2; used.has(name); suffix++) name = `${base}_${suffix}`;
8
+ used.add(name);
9
+ return name;
10
+ }
11
+ const models = [...schema.models].sort((a, b) =>
12
+ a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
13
+ );
14
+ const names = new Map(
15
+ models.map((model) => [model.key, symbol(`Model_${model.key}`)]),
16
+ );
17
+ const records = [];
18
+ const choices = [];
19
+ function fields(values, owner) {
20
+ return values.map((field) => {
21
+ const result = { ...field };
22
+ if (['choice', 'choices'].includes(field.type)) {
23
+ result.name = symbol(`Choice_${owner}_${field.key}`);
24
+ choices.push(result);
25
+ }
26
+ if (['component', 'components', 'blocks'].includes(field.type)) {
27
+ result.name = symbol(`Block_${owner}_${field.key}`);
28
+ result.variants = field.components.map((component) => {
29
+ const name = symbol(`Component_${owner}_${field.key}_${component.key}`);
30
+ const record = {
31
+ name,
32
+ key: component.key,
33
+ fields: fields(component.fields, name),
34
+ component: true,
35
+ parent: result.name,
36
+ };
37
+ records.push(record);
38
+ return record;
39
+ });
40
+ }
41
+ return result;
42
+ });
43
+ }
44
+ for (const model of models)
45
+ records.push({
46
+ name: names.get(model.key),
47
+ key: model.key,
48
+ fields: fields(model.fields, model.key),
49
+ component: false,
50
+ });
51
+ return { records, choices, names };
52
+ }
@@ -0,0 +1,16 @@
1
+ import { validateSchema } from '../cli/schema.mjs';
2
+ import { generateTypes } from '../generate-types.mjs';
3
+ import { generatePython } from './python.mjs';
4
+ import { generateCSharp } from './csharp.mjs';
5
+ import { languages } from './definitions.mjs';
6
+
7
+ export { languages } from './definitions.mjs';
8
+
9
+ // All new targets consume the same bounded, validated export. The legacy
10
+ // TypeScript generator and its byte-for-byte output remain compatible.
11
+ export function generateLanguage(schema, language = 'typescript') {
12
+ if (!languages.includes(language)) throw new Error('Unsupported output language.');
13
+ validateSchema(schema);
14
+ if (language === 'typescript') return generateTypes(schema);
15
+ return language === 'python' ? generatePython(schema) : generateCSharp(schema);
16
+ }