forked from
mary.my.id/atcute
a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
1import { dirname as getDirname, relative as getRelativePath } from 'node:path/posix';
2
3import type {
4 LexDefinableField,
5 LexiconDoc,
6 LexObject,
7 LexRecord,
8 LexRefVariant,
9 LexUnknown,
10 LexUserType,
11 LexXrpcBody,
12 LexXrpcParameters,
13 LexXrpcProcedure,
14 LexXrpcQuery,
15 LexXrpcSubscription,
16} from '@atcute/lexicon-doc';
17import { formatLexiconRef, parseLexiconRef, type ParsedLexiconRef } from '@atcute/lexicon-doc';
18
19import * as prettier from 'prettier';
20
21export interface SourceFile {
22 filename: string;
23 code: string;
24}
25
26export interface ImportMapping {
27 nsid: string[];
28 imports: string | ((nsid: string) => { type: 'named' | 'namespace'; from: string });
29}
30
31export interface LexiconApiOptions {
32 documents: LexiconDoc[];
33 mappings: ImportMapping[];
34 modules: {
35 importSuffix: string;
36 };
37 prettier: {
38 cwd: string;
39 };
40}
41
42export interface LexiconApiResult {
43 files: SourceFile[];
44}
45
46type DocumentMap = Map<string, LexiconDoc>;
47type ImportSet = Set<string>;
48
49type Literal = string | number | boolean;
50
51const lit: (val: Literal | Literal[]) => string = JSON.stringify;
52
53const resolvePath = (from: ParsedLexiconRef, ref: string): ParsedLexiconRef => {
54 return parseLexiconRef(ref, from.nsid);
55};
56
57const resolveExternalImport = (nsid: string, mappings: ImportMapping[]): ImportMapping | undefined => {
58 return mappings.find((mapping) => {
59 return mapping.nsid.some((pattern) => {
60 if (pattern.endsWith('.*')) {
61 return nsid.startsWith(pattern.slice(0, -1));
62 }
63
64 return nsid === pattern;
65 });
66 });
67};
68
69const PURE = `/*#__PURE__*/`;
70
71export const generateLexiconApi = async (opts: LexiconApiOptions): Promise<LexiconApiResult> => {
72 const importExt = opts.modules?.importSuffix;
73
74 const documents = opts.documents.toSorted((a, b) => {
75 if (a.id < b.id) {
76 return -1;
77 }
78 if (a.id > b.id) {
79 return 1;
80 }
81
82 return 0;
83 });
84
85 const map: DocumentMap = new Map(documents.map((doc) => [doc.id, doc]));
86 const files: SourceFile[] = [];
87 const generatedIds = new Set<string>();
88
89 for (const doc of documents) {
90 const filename = `types/${doc.id.replaceAll('.', '/')}.ts`;
91 const file = {
92 imports: '',
93 rawschemas: '',
94 schemadefs: '',
95 schemas: '',
96 interfaces: '',
97 sinterfaces: '',
98 exports: '',
99 ambients: '',
100 };
101
102 file.imports += `import type {} from '@atcute/lexicons';\n`;
103 file.imports += `import * as v from '@atcute/lexicons/validations';\n`;
104
105 const imports = new Set<string>();
106
107 const sortedDefIds = Object.keys(doc.defs).toSorted((a, b) => {
108 if (a < b) {
109 return -1;
110 }
111 if (a > b) {
112 return 1;
113 }
114
115 return 0;
116 });
117
118 for (const defId of sortedDefIds) {
119 const def = doc.defs[defId];
120 const path: ParsedLexiconRef = { nsid: doc.id, defId };
121
122 const camelcased = toCamelCase(defId);
123 const varname = `${camelcased}Schema`;
124
125 let result: string;
126 switch (def.type) {
127 case 'query': {
128 result = generateXrpcQuery(imports, path, def);
129
130 file.imports += `import type {} from '@atcute/lexicons/ambient';\n`;
131
132 file.ambients += `declare module '@atcute/lexicons/ambient' {\n`;
133 file.ambients += ` interface XRPCQueries {\n`;
134 file.ambients += ` ${lit(formatLexiconRef(path))}: ${camelcased}Schema;\n`;
135 file.ambients += ` }\n`;
136 file.ambients += `}`;
137 break;
138 }
139 case 'procedure': {
140 result = generateXrpcProcedure(imports, path, def);
141
142 file.imports += `import type {} from '@atcute/lexicons/ambient';\n`;
143
144 file.ambients += `declare module '@atcute/lexicons/ambient' {\n`;
145 file.ambients += ` interface XRPCProcedures {\n`;
146 file.ambients += ` ${lit(formatLexiconRef(path))}: ${camelcased}Schema;\n`;
147 file.ambients += ` }\n`;
148 file.ambients += `}`;
149 break;
150 }
151 case 'subscription': {
152 result = generateXrpcSubscription(imports, path, def);
153
154 file.imports += `import type {} from '@atcute/lexicons/ambient';\n`;
155
156 file.ambients += `declare module '@atcute/lexicons/ambient' {\n`;
157 file.ambients += ` interface XRPCSubscriptions {\n`;
158 file.ambients += ` ${lit(formatLexiconRef(path))}: ${camelcased}Schema;\n`;
159 file.ambients += ` }\n`;
160 file.ambients += `}`;
161 break;
162 }
163 case 'object': {
164 result = generateObject(imports, path, def);
165 break;
166 }
167 case 'record': {
168 result = generateRecord(imports, path, def);
169
170 file.imports += `import type {} from '@atcute/lexicons/ambient';\n`;
171
172 file.ambients += `declare module '@atcute/lexicons/ambient' {\n`;
173 file.ambients += ` interface Records {\n`;
174 file.ambients += ` ${lit(formatLexiconRef(path))}: ${camelcased}Schema;\n`;
175 file.ambients += ` }\n`;
176 file.ambients += `}`;
177 break;
178 }
179 case 'token': {
180 result = `${PURE} v.literal(${lit(formatLexiconRef(path))})`;
181 break;
182 }
183 case 'permission-set': {
184 // skip permission sets
185 continue;
186 }
187 default: {
188 result = generateType(imports, path, def);
189 break;
190 }
191 }
192
193 file.rawschemas += `const _${varname} = ${result};\n`;
194
195 file.schemadefs += `type ${camelcased}$schematype = typeof _${varname};\n`;
196
197 file.schemas += `export interface ${camelcased}Schema extends ${camelcased}$schematype {}\n`;
198
199 file.exports += `export const ${varname} = _${varname} as ${camelcased}Schema;\n`;
200
201 switch (def.type) {
202 case 'query': {
203 if (def.parameters) {
204 file.sinterfaces += `export interface $params extends v.InferInput<${camelcased}Schema['params']> {}\n`;
205 } else {
206 file.sinterfaces += `export interface $params {}\n`;
207 }
208
209 if (def.output?.schema) {
210 if (def.output?.schema.type === 'object') {
211 file.sinterfaces += `export interface $output extends v.InferXRPCBodyInput<${camelcased}Schema['output']> {}\n`;
212 } else {
213 file.sinterfaces += `export type $output = v.InferXRPCBodyInput<${camelcased}Schema['output']>;\n`;
214 }
215 } else if (def.output) {
216 file.sinterfaces += `export type $output = v.InferXRPCBodyInput<${camelcased}Schema['output']>;\n`;
217 }
218
219 break;
220 }
221 case 'procedure': {
222 if (def.parameters) {
223 file.sinterfaces += `export interface $params extends v.InferInput<${camelcased}Schema['params']> {}\n`;
224 } else {
225 file.sinterfaces += `export interface $params {}\n`;
226 }
227
228 if (def.input?.schema) {
229 if (def.input?.schema.type === 'object') {
230 file.sinterfaces += `export interface $input extends v.InferXRPCBodyInput<${camelcased}Schema['input']> {}\n`;
231 } else {
232 file.sinterfaces += `export type $input = v.InferXRPCBodyInput<${camelcased}Schema['input']>;\n`;
233 }
234 } else if (def.input) {
235 file.sinterfaces += `export type $input = v.InferXRPCBodyInput<${camelcased}Schema['input']>;\n`;
236 }
237
238 if (def.output?.schema) {
239 if (def.output?.schema.type === 'object') {
240 file.sinterfaces += `export interface $output extends v.InferXRPCBodyInput<${camelcased}Schema['output']> {}\n`;
241 } else {
242 file.sinterfaces += `export type $output = v.InferXRPCBodyInput<${camelcased}Schema['output']>;\n`;
243 }
244 } else if (def.output) {
245 file.sinterfaces += `export type $output = v.InferXRPCBodyInput<${camelcased}Schema['output']>;\n`;
246 }
247
248 break;
249 }
250 case 'subscription': {
251 if (def.parameters) {
252 file.sinterfaces += `export interface $params extends v.InferInput<${camelcased}Schema['params']> {}\n`;
253 } else {
254 file.sinterfaces += `export interface $params {}\n`;
255 }
256
257 if (def.message?.schema) {
258 file.sinterfaces += `export type $message = v.InferInput<${camelcased}Schema['message']>;\n`;
259 }
260
261 break;
262 }
263
264 case 'array':
265 case 'object':
266 case 'record':
267 case 'unknown': {
268 file.interfaces += `export interface ${toTitleCase(defId)} extends v.InferInput<typeof ${varname}> {}\n`;
269 break;
270 }
271 case 'blob':
272 case 'boolean':
273 case 'bytes':
274 case 'cid-link':
275 case 'integer':
276 case 'string':
277 case 'token': {
278 file.interfaces += `export type ${toTitleCase(defId)} = v.InferInput<typeof ${varname}>;\n`;
279 break;
280 }
281 }
282 }
283
284 {
285 const dirname = getDirname(filename);
286
287 const sortedImports = [...imports].toSorted((a, b) => {
288 if (a < b) {
289 return -1;
290 }
291 if (a > b) {
292 return 1;
293 }
294
295 return 0;
296 });
297
298 for (const ns of sortedImports) {
299 const local = map.get(ns);
300
301 if (local) {
302 const target = `types/${ns.replaceAll('.', '/')}${importExt}`;
303
304 let relative = getRelativePath(dirname, target);
305 if (!relative.startsWith('.')) {
306 relative = `./${relative}`;
307 }
308
309 file.imports += `import * as ${toTitleCase(ns)} from ${lit(relative)};\n`;
310 continue;
311 }
312
313 const external = resolveExternalImport(ns, opts.mappings);
314
315 if (external) {
316 if (typeof external.imports === 'function') {
317 const res = external.imports(ns);
318
319 if (res.type === 'named') {
320 file.imports += `import { ${toTitleCase(ns)} } from ${lit(res.from)};\n`;
321 } else if (res.type === 'namespace') {
322 file.imports += `import * as ${toTitleCase(ns)} from ${lit(res.from)};\n`;
323 }
324 } else {
325 file.imports += `import { ${toTitleCase(ns)} } from ${lit(external.imports)};\n`;
326 }
327
328 continue;
329 }
330
331 throw new Error(`'${doc.id}' referenced non-existent '${ns}' namespace`);
332 }
333 }
334
335 // skip files that only have imports and no actual content
336 if (file.exports) {
337 generatedIds.add(doc.id);
338
339 files.push({
340 filename: filename,
341 code:
342 file.imports +
343 `\n\n` +
344 file.rawschemas +
345 `\n\n` +
346 file.schemadefs +
347 `\n\n` +
348 file.schemas +
349 `\n\n` +
350 file.exports +
351 `\n\n` +
352 file.interfaces +
353 `\n\n` +
354 file.sinterfaces +
355 `\n\n` +
356 file.ambients,
357 });
358 }
359 }
360
361 {
362 let code = ``;
363
364 for (const doc of map.values()) {
365 if (!generatedIds.has(doc.id)) {
366 continue;
367 }
368
369 code += `export * as ${toTitleCase(doc.id)} from ${lit(`./types/${doc.id.replaceAll('.', '/')}${importExt}`)};\n`;
370 }
371
372 files.push({
373 filename: 'index.ts',
374 code: code,
375 });
376 }
377
378 if (opts.prettier) {
379 const config = await prettier.resolveConfig(opts.prettier.cwd, { editorconfig: true });
380
381 for (const file of files) {
382 const formatted = await prettier.format(file.code, { ...config, parser: 'typescript' });
383 file.code = formatted;
384 }
385 }
386
387 return { files };
388};
389
390const generateXrpcQuery = (imports: ImportSet, path: ParsedLexiconRef, spec: LexXrpcQuery): string => {
391 const params = generateXrpcParameters(imports, path, spec.parameters);
392 const output = generateXrpcBody(imports, path, spec.output);
393
394 return `${PURE} v.query(${lit(formatLexiconRef(path))}, {\n"params": ${params}, "output": ${output} })`;
395};
396
397const generateXrpcProcedure = (
398 imports: ImportSet,
399 path: ParsedLexiconRef,
400 spec: LexXrpcProcedure,
401): string => {
402 const params = generateXrpcParameters(imports, path, spec.parameters);
403 const input = generateXrpcBody(imports, path, spec.input);
404 const output = generateXrpcBody(imports, path, spec.output);
405
406 return `${PURE} v.procedure(${lit(formatLexiconRef(path))}, {\n"params": ${params}, "input": ${input}, "output": ${output} })`;
407};
408
409const generateXrpcSubscription = (
410 imports: ImportSet,
411 path: ParsedLexiconRef,
412 spec: LexXrpcSubscription,
413): string => {
414 const schema = spec.message?.schema;
415
416 const params = generateXrpcParameters(imports, path, spec.parameters);
417
418 let inner = ``;
419
420 inner += `"params": ${params},`;
421
422 if (schema) {
423 const res = generateType(imports, path, schema);
424
425 inner += `get "message" () { return ${res} },`;
426 } else {
427 inner += `"message": null,`;
428 }
429
430 return `${PURE} v.subscription(${lit(formatLexiconRef(path))}, {\n${inner}})`;
431};
432
433const generateXrpcBody = (
434 imports: ImportSet,
435 path: ParsedLexiconRef,
436 spec: LexXrpcBody | undefined,
437): string => {
438 if (spec === undefined) {
439 return `null`;
440 }
441
442 const schema = spec.schema;
443 const encoding = spec.encoding;
444
445 if (schema) {
446 let inner = ``;
447
448 inner += `"type": "lex",`;
449
450 if (schema.type === 'object') {
451 const res = generateObject(imports, path, schema, 'none');
452
453 inner += `"schema": ${res},`;
454 } else {
455 const res = generateType(imports, path, schema);
456
457 inner += `get "schema" () { return ${res} },`;
458 }
459
460 return `{\n${inner}}`;
461 }
462
463 if (encoding) {
464 const types = encoding.split(',').map((type) => type.trim());
465
466 let inner = ``;
467
468 inner += `"type": "blob",`;
469
470 if (types.length > 1 || types[0] !== '*/*') {
471 inner += `"encoding": ${lit(types)},`;
472 }
473
474 return `{\n${inner}}`;
475 }
476
477 return `null`;
478};
479
480const generateXrpcParameters = (
481 imports: ImportSet,
482 path: ParsedLexiconRef,
483 spec: LexXrpcParameters | undefined,
484): string => {
485 if (spec === undefined) {
486 return `null`;
487 }
488
489 const requiredProps = spec.required;
490 const originalProperties = spec.properties;
491 let transformedProperties: LexXrpcParameters['properties'] | undefined;
492
493 if (originalProperties) {
494 for (const [prop, propSpec] of Object.entries(originalProperties)) {
495 if (propSpec.type === 'array') {
496 if (!requiredProps?.includes(prop)) {
497 continue;
498 }
499
500 if (transformedProperties === undefined) {
501 transformedProperties = { ...originalProperties };
502 }
503
504 transformedProperties[prop] = {
505 ...propSpec,
506 minLength: Math.max(propSpec.minLength ?? 0, 1),
507 };
508 }
509 }
510 }
511
512 const mask: LexObject = {
513 type: 'object',
514 description: spec.description,
515 required: spec.required,
516 properties: transformedProperties ?? originalProperties,
517 };
518
519 return generateObject(imports, path, mask, 'none');
520};
521
522const generateRecord = (imports: ImportSet, path: ParsedLexiconRef, spec: LexRecord): string => {
523 const schema = generateObject(imports, path, spec.record, 'required');
524
525 let key = `${PURE} v.string()`;
526 if (spec.key) {
527 if (spec.key === 'tid') {
528 key = `${PURE} v.tidString()`;
529 } else if (spec.key === 'nsid') {
530 key = `${PURE} v.nsidString()`;
531 } else if (spec.key.startsWith('literal:')) {
532 key = `${PURE} v.literal(${lit(spec.key.slice('literal:'.length))})`;
533 }
534 }
535
536 return `${PURE} v.record(${key}, ${schema})`;
537};
538
539const generateObject = (
540 imports: ImportSet,
541 path: ParsedLexiconRef,
542 spec: LexObject,
543 writeType: 'required' | 'optional' | 'none' = 'optional',
544): string => {
545 const required = new Set(spec.required);
546 const nullable = new Set(spec.nullable);
547
548 let inner = ``;
549
550 switch (writeType) {
551 case 'optional': {
552 inner += `"$type": ${PURE} v.optional(${PURE} v.literal(${lit(formatLexiconRef(path))})),`;
553 break;
554 }
555 case 'required': {
556 inner += `"$type": ${PURE} v.literal(${lit(formatLexiconRef(path))}),`;
557 break;
558 }
559 }
560
561 const sortedEntries = Object.entries(spec.properties ?? {}).toSorted(([keyA], [keyB]) => {
562 if (keyA < keyB) {
563 return -1;
564 }
565 if (keyA > keyB) {
566 return 1;
567 }
568
569 return 0;
570 });
571
572 for (const [prop, propSpec] of sortedEntries) {
573 const lazy = isRefVariant(propSpec.type === 'array' ? propSpec.items : propSpec);
574 const optional = !required.has(prop) && !('default' in propSpec && propSpec.default !== undefined);
575 const nulled = nullable.has(prop);
576
577 let call = generateType(imports, path, propSpec, lazy);
578
579 if (nulled) {
580 call = `${PURE} v.nullable(${call})`;
581 }
582
583 if (optional) {
584 call = `${PURE} v.optional(${call})`;
585 }
586
587 const jsdoc = generateJsdocField(propSpec);
588 if (jsdoc.length !== 0) {
589 inner += `\n${jsdoc}\n`;
590 }
591
592 if (lazy) {
593 inner += `get ${lit(prop)} () { return ${call} },`;
594 } else {
595 inner += `${lit(prop)}: ${call},`;
596 }
597 }
598
599 return `${PURE} v.object({\n${inner}})`;
600};
601
602const IS_DEPRECATED_PREFIX_RE = /^\s*(?:\(deprecated\)|deprecated[.:;])/i;
603const IS_DEPRECATED_SUFFIX_RE = /\b(?:deprecated(?::[^]+)?)\s*$/i;
604
605const generateJsdocField = (spec: LexUserType | LexRefVariant | LexUnknown) => {
606 const lines: string[] = [];
607
608 if ('description' in spec && spec.description) {
609 let desc = spec.description
610 .replace(/\*\//g, '*\\/')
611 .replace(/@/g, '\\@')
612 .replace(/\r?\n/g, ' ')
613 .replace(/\s+/g, ' ')
614 .trim();
615
616 if (desc) {
617 lines.push(desc);
618 }
619
620 if (IS_DEPRECATED_PREFIX_RE.test(desc) || IS_DEPRECATED_SUFFIX_RE.test(desc)) {
621 lines.push(`@deprecated`);
622 }
623 }
624
625 // Add annotations based on property spec type
626 switch (spec.type) {
627 case 'boolean': {
628 if (spec.default !== undefined) {
629 lines.push(`@default ${lit(spec.default)}`);
630 }
631 break;
632 }
633 case 'string': {
634 if (spec.minLength !== undefined) {
635 lines.push(`@minLength ${spec.minLength}`);
636 }
637 if (spec.maxLength !== undefined) {
638 lines.push(`@maxLength ${spec.maxLength}`);
639 }
640 if (spec.minGraphemes !== undefined) {
641 lines.push(`@minGraphemes ${spec.minGraphemes}`);
642 }
643 if (spec.maxGraphemes !== undefined) {
644 lines.push(`@maxGraphemes ${spec.maxGraphemes}`);
645 }
646 if (spec.default !== undefined) {
647 lines.push(`@default ${lit(spec.default)}`);
648 }
649 break;
650 }
651 case 'integer': {
652 if (spec.minimum !== undefined) {
653 lines.push(`@minimum ${spec.minimum}`);
654 }
655 if (spec.maximum !== undefined) {
656 lines.push(`@maximum ${spec.maximum}`);
657 }
658 if (spec.default !== undefined) {
659 lines.push(`@default ${lit(spec.default)}`);
660 }
661 break;
662 }
663 case 'bytes': {
664 if (spec.minLength !== undefined) {
665 lines.push(`@minLength ${spec.minLength}`);
666 }
667 if (spec.maxLength !== undefined) {
668 lines.push(`@maxLength ${spec.maxLength}`);
669 }
670 break;
671 }
672 case 'array': {
673 if (spec.minLength !== undefined) {
674 lines.push(`@minLength ${spec.minLength}`);
675 }
676 if (spec.maxLength !== undefined) {
677 lines.push(`@maxLength ${spec.maxLength}`);
678 }
679 break;
680 }
681 case 'blob': {
682 if (spec.accept) {
683 const accept = spec.accept.map((mime) => mime.replace(/\*\//g, '*\\/')).join(', ');
684 lines.push(`@accept ${accept}`);
685 }
686 if (spec.maxSize !== undefined) {
687 lines.push(`@maxSize ${spec.maxSize}`);
688 }
689 break;
690 }
691 }
692
693 let res = ``;
694 if (lines.length > 0) {
695 res += `/**\n`;
696
697 for (let idx = 0, len = lines.length; idx < len; idx++) {
698 const line = lines[idx];
699 res += ` * ${line}\n`;
700 }
701
702 res += `*/`;
703 }
704
705 return res;
706};
707
708const generateType = (
709 imports: ImportSet,
710 path: ParsedLexiconRef,
711 spec: LexDefinableField,
712 lazy = false,
713): string => {
714 switch (spec.type) {
715 // LexRefVariant
716 case 'ref': {
717 const refPath = resolvePath(path, spec.ref);
718
719 if (refPath.nsid === path.nsid) {
720 return `${toCamelCase(refPath.defId)}Schema`;
721 }
722
723 imports.add(refPath.nsid);
724 return `${toTitleCase(refPath.nsid)}.${toCamelCase(refPath.defId)}Schema`;
725 }
726 case 'union': {
727 const refs = spec.refs
728 .map((ref) => {
729 const refPath = resolvePath(path, ref);
730 return { path: refPath, uri: formatLexiconRef(refPath) };
731 })
732 // oxlint-disable-next-line unicorn/no-array-sort -- map already clones
733 .sort((a, b) => {
734 if (a.uri < b.uri) {
735 return -1;
736 }
737 if (a.uri > b.uri) {
738 return 1;
739 }
740
741 return 0;
742 })
743 .map(({ path: refPath }): string => {
744 if (refPath.nsid === path.nsid) {
745 return `${toCamelCase(refPath.defId)}Schema`;
746 }
747
748 imports.add(refPath.nsid);
749 return `${toTitleCase(refPath.nsid)}.${toCamelCase(refPath.defId)}Schema`;
750 });
751
752 return `${PURE} v.variant([${refs.join(', ')}]${spec.closed ? `, true` : ``})`;
753 }
754
755 // LexArray
756 case 'array': {
757 let item = generateType(imports, path, spec.items);
758 if (!lazy && (spec.items.type === 'ref' || spec.items.type === 'union')) {
759 item = `(() => { return ${item}; })`;
760 }
761
762 let pipe: string[] = [];
763
764 if ((spec.minLength ?? 0) > 0 || spec.maxLength !== undefined) {
765 if (spec.maxLength === undefined) {
766 pipe.push(`${PURE} v.arrayLength(${lit(spec.minLength ?? 0)})`);
767 } else {
768 pipe.push(`${PURE} v.arrayLength(${lit(spec.minLength ?? 0)}, ${lit(spec.maxLength)})`);
769 }
770 }
771
772 let call = `${PURE} v.array(${item})`;
773
774 if (pipe.length !== 0) {
775 call = `${PURE} v.constrain(${call}, [ ${pipe.join(', ')} ])`;
776 }
777
778 return call;
779 }
780
781 // LexPrimitive
782 case 'boolean': {
783 let call = `${PURE} v.boolean()`;
784
785 if (spec.const !== undefined) {
786 call = `${PURE} v.literal(${spec.const})`;
787 }
788
789 if (spec.default !== undefined) {
790 call = `${PURE} v.optional(${call}, ${lit(spec.default)})`;
791 }
792
793 return call;
794 }
795 case 'integer': {
796 let pipe: string[] = [];
797
798 if ((spec.minimum ?? 0) > 0 || spec.maximum !== undefined) {
799 if (spec.maximum === undefined) {
800 pipe.push(`${PURE} v.integerRange(${lit(spec.minimum ?? 0)})`);
801 } else {
802 pipe.push(`${PURE} v.integerRange(${lit(spec.minimum ?? 0)}, ${lit(spec.maximum)})`);
803 }
804 }
805
806 let call = `${PURE} v.integer()`;
807
808 if (spec.const !== undefined) {
809 call = `${PURE} v.literal(${lit(spec.const)})`;
810 } else if (spec.enum !== undefined) {
811 call = `${PURE} v.literalEnum(${lit(spec.enum.toSorted())})`;
812 } else if (pipe.length !== 0) {
813 call = `${PURE} v.constrain(${call}, [ ${pipe.join(', ')} ])`;
814 }
815
816 if (spec.default !== undefined) {
817 call = `${PURE} v.optional(${call}, ${lit(spec.default)})`;
818 }
819
820 return call;
821 }
822 case 'string': {
823 let pipe: string[] = [];
824
825 if ((spec.minLength ?? 0) > 0 || spec.maxLength !== undefined) {
826 if (spec.maxLength === undefined) {
827 pipe.push(`${PURE} v.stringLength(${lit(spec.minLength ?? 0)})`);
828 } else {
829 pipe.push(`${PURE} v.stringLength(${lit(spec.minLength ?? 0)}, ${lit(spec.maxLength)})`);
830 }
831 }
832
833 if ((spec.minGraphemes ?? 0) > 0 || spec.maxGraphemes !== undefined) {
834 if (spec.maxGraphemes === undefined) {
835 pipe.push(`${PURE} v.stringGraphemes(${lit(spec.minGraphemes ?? 0)})`);
836 } else {
837 pipe.push(`${PURE} v.stringGraphemes(${lit(spec.minGraphemes ?? 0)}, ${lit(spec.maxGraphemes)})`);
838 }
839 }
840
841 let call = `${PURE} v.string()`;
842
843 if (spec.knownValues?.length) {
844 call = `${PURE} v.string<${spec.knownValues.toSorted().map(lit).join(' | ')} | (string & {})>()`;
845 }
846
847 switch (spec.format) {
848 case 'at-identifier': {
849 call = `${PURE} v.actorIdentifierString()`;
850 break;
851 }
852 case 'at-uri': {
853 call = `${PURE} v.resourceUriString()`;
854 break;
855 }
856 case 'cid': {
857 call = `${PURE} v.cidString()`;
858 break;
859 }
860 case 'datetime': {
861 call = `${PURE} v.datetimeString()`;
862 break;
863 }
864 case 'did': {
865 call = `${PURE} v.didString()`;
866 break;
867 }
868 case 'handle': {
869 call = `${PURE} v.handleString()`;
870 break;
871 }
872 case 'language': {
873 call = `${PURE} v.languageCodeString()`;
874 break;
875 }
876 case 'nsid': {
877 call = `${PURE} v.nsidString()`;
878 break;
879 }
880 case 'record-key': {
881 call = `${PURE} v.recordKeyString()`;
882 break;
883 }
884 case 'tid': {
885 call = `${PURE} v.tidString()`;
886 break;
887 }
888 case 'uri': {
889 call = `${PURE} v.genericUriString()`;
890 break;
891 }
892 }
893
894 if (spec.const !== undefined) {
895 call = `${PURE} v.literal(${lit(spec.const)})`;
896 } else if (spec.enum !== undefined) {
897 call = `${PURE} v.literalEnum(${lit(spec.enum.toSorted())})`;
898 } else if (pipe.length !== 0) {
899 call = `${PURE} v.constrain(${call}, [ ${pipe.join(', ')} ])`;
900 }
901
902 if (spec.default !== undefined) {
903 call = `${PURE} v.optional(${call}, ${lit(spec.default)})`;
904 }
905
906 return call;
907 }
908 case 'unknown': {
909 return `${PURE} v.unknown()`;
910 }
911
912 // LexBlob
913 case 'blob': {
914 return `${PURE} v.blob()`;
915 }
916
917 // LexIpldType
918 case 'bytes': {
919 let pipe: string[] = [];
920
921 if ((spec.minLength ?? 0) > 0 || spec.maxLength !== undefined) {
922 if (spec.maxLength === undefined) {
923 pipe.push(`${PURE} v.bytesSize(${lit(spec.minLength ?? 0)})`);
924 } else {
925 pipe.push(`${PURE} v.bytesSize(${lit(spec.minLength ?? 0)}, ${lit(spec.maxLength)})`);
926 }
927 }
928
929 let call = `${PURE} v.bytes()`;
930
931 if (pipe.length !== 0) {
932 call = `${PURE} v.constrain(${call}, [ ${pipe.join(', ')} ])`;
933 }
934
935 return call;
936 }
937 case 'cid-link': {
938 return `${PURE} v.cidLink()`;
939 }
940 }
941};
942
943const isRefVariant = (spec: LexDefinableField): spec is LexRefVariant => {
944 const type = spec.type;
945 return type === 'ref' || type === 'union';
946};
947
948const toTitleCase = (v: string): string => {
949 v = v.replace(/^([a-z])/gi, (_, g) => g.toUpperCase());
950 v = v.replace(/[.#-]([a-z])/gi, (_, g) => g.toUpperCase());
951 return v.replace(/[.-]/g, '');
952};
953
954const toCamelCase = (v: string): string => {
955 v = v.replace(/^([A-Z])/gi, (_, g) => g.toLowerCase());
956 v = v.replace(/[.#-]([a-z])/gi, (_, g) => g.toUpperCase());
957 return v.replace(/[.-]/g, '');
958};