vite-plugin-ferry 0.1.3 → 0.1.4

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 CHANGED
@@ -44,7 +44,9 @@ import type { UserResource } from '@ferry/resources';
44
44
 
45
45
  ### Enums
46
46
 
47
- A PHP enum with labels:
47
+ #### String-backed enum with labels
48
+
49
+ When your enum has a `label()` method, Ferry generates a typed constant object:
48
50
 
49
51
  ```php
50
52
  // app/Enums/OrderStatus.php
@@ -76,9 +78,86 @@ export declare const OrderStatus: {
76
78
  };
77
79
  ```
78
80
 
81
+ #### String-backed enum without labels
82
+
83
+ Simple string enums become TypeScript enums:
84
+
85
+ ```php
86
+ // app/Enums/Role.php
87
+ enum Role: string
88
+ {
89
+ case ADMIN = 'admin';
90
+ case USER = 'user';
91
+ case GUEST = 'guest';
92
+ }
93
+ ```
94
+
95
+ Generates:
96
+
97
+ ```ts
98
+ // @ferry/enums
99
+ export enum Role {
100
+ ADMIN = 'admin',
101
+ USER = 'user',
102
+ GUEST = 'guest',
103
+ }
104
+ ```
105
+
106
+ #### Int-backed enum
107
+
108
+ Integer enums work the same way:
109
+
110
+ ```php
111
+ // app/Enums/Priority.php
112
+ enum Priority: int
113
+ {
114
+ case LOW = 1;
115
+ case MEDIUM = 2;
116
+ case HIGH = 3;
117
+ case URGENT = 4;
118
+ }
119
+ ```
120
+
121
+ Generates:
122
+
123
+ ```ts
124
+ // @ferry/enums
125
+ export enum Priority {
126
+ LOW = 1,
127
+ MEDIUM = 2,
128
+ HIGH = 3,
129
+ URGENT = 4,
130
+ }
131
+ ```
132
+
133
+ #### Unit enum (no backing type)
134
+
135
+ Unit enums use their case names as values:
136
+
137
+ ```php
138
+ // app/Enums/Color.php
139
+ enum Color
140
+ {
141
+ case RED;
142
+ case GREEN;
143
+ case BLUE;
144
+ }
145
+ ```
146
+
147
+ Generates:
148
+
149
+ ```ts
150
+ // @ferry/enums
151
+ export enum Color {
152
+ RED = 'RED',
153
+ GREEN = 'GREEN',
154
+ BLUE = 'BLUE',
155
+ }
156
+ ```
157
+
79
158
  ### Resources
80
159
 
81
- A Laravel JsonResource:
160
+ #### Basic resource
82
161
 
83
162
  ```php
84
163
  // app/Http/Resources/UserResource.php
@@ -90,8 +169,8 @@ class UserResource extends JsonResource
90
169
  'id' => $this->resource->id,
91
170
  'name' => $this->resource->name,
92
171
  'email' => $this->resource->email,
172
+ 'is_admin' => $this->resource->is_admin,
93
173
  'created_at' => $this->resource->created_at,
94
- 'posts' => PostResource::collection($this->whenLoaded('posts')),
95
174
  ];
96
175
  }
97
176
  }
@@ -105,11 +184,102 @@ export type UserResource = {
105
184
  id: string;
106
185
  name: string;
107
186
  email: string;
187
+ is_admin: boolean;
108
188
  created_at: string;
109
- posts?: PostResource[];
110
189
  };
111
190
  ```
112
191
 
192
+ #### Resource with relations
193
+
194
+ Fields using `whenLoaded()` become optional and resolve to the correct resource type:
195
+
196
+ ```php
197
+ // app/Http/Resources/PostResource.php
198
+ class PostResource extends JsonResource
199
+ {
200
+ public function toArray(Request $request): array
201
+ {
202
+ return [
203
+ 'id' => $this->resource->id,
204
+ 'title' => $this->resource->title,
205
+ 'slug' => $this->resource->slug,
206
+ 'is_published' => $this->resource->is_published,
207
+ 'author' => UserResource::make($this->whenLoaded('author')),
208
+ 'comments' => CommentResource::collection($this->whenLoaded('comments')),
209
+ 'created_at' => $this->resource->created_at,
210
+ ];
211
+ }
212
+ }
213
+ ```
214
+
215
+ Generates:
216
+
217
+ ```ts
218
+ // @ferry/resources
219
+ export type PostResource = {
220
+ id: string;
221
+ title: string;
222
+ slug: string;
223
+ is_published: boolean;
224
+ author?: UserResource[];
225
+ comments?: CommentResource[];
226
+ created_at: string;
227
+ };
228
+ ```
229
+
230
+ #### Resource with nested objects
231
+
232
+ Inline array structures become typed objects:
233
+
234
+ ```php
235
+ // app/Http/Resources/OrderResource.php
236
+ class OrderResource extends JsonResource
237
+ {
238
+ public function toArray(Request $request): array
239
+ {
240
+ return [
241
+ 'id' => $this->resource->id,
242
+ 'total' => $this->resource->total,
243
+ 'status' => $this->resource->status,
244
+ 'items' => $this->resource->items,
245
+ 'user' => $this->whenLoaded('user'),
246
+ 'shipping_address' => [
247
+ 'street' => $this->resource->address_street,
248
+ 'city' => $this->resource->address_city,
249
+ 'zip' => $this->resource->address_zip,
250
+ ],
251
+ 'created_at' => $this->resource->created_at,
252
+ ];
253
+ }
254
+ }
255
+ ```
256
+
257
+ Generates:
258
+
259
+ ```ts
260
+ // @ferry/resources
261
+ export type OrderResource = {
262
+ id: string;
263
+ total: string;
264
+ status: string;
265
+ items: string;
266
+ user?: UserResource;
267
+ shipping_address: { street: string; city: string; zip: string };
268
+ created_at: string;
269
+ };
270
+ ```
271
+
272
+ ## Publishing
273
+
274
+ To publish a new version:
275
+
276
+ ```bash
277
+ npm version patch # or minor, major
278
+ git push --follow-tags
279
+ ```
280
+
281
+ This bumps the version, creates a commit and tag, then pushes both to trigger the publish workflow.
282
+
113
283
  ## License
114
284
 
115
285
  See [LICENSE](LICENSE) for details.
@@ -1,4 +1,4 @@
1
- import { type EnumDefinition } from '../utils/php-parser.js';
1
+ import { type ResourceFieldInfo } from '../utils/php-parser.js';
2
2
  export type ResourceGeneratorOptions = {
3
3
  resourcesDir: string;
4
4
  enumsDir: string;
@@ -7,18 +7,11 @@ export type ResourceGeneratorOptions = {
7
7
  packageName: string;
8
8
  prettyPrint?: boolean;
9
9
  };
10
- export type FieldInfo = {
11
- type: string;
12
- optional: boolean;
13
- };
14
- /**
15
- * Parse fields from a PHP array block (from toArray() method).
16
- */
17
- export declare function parseFieldsFromArrayBlock(block: string, resourceClass: string, docShape: Record<string, string> | null, resourcesDir: string, modelsDir: string, enumsDir: string, collectedEnums: Record<string, EnumDefinition>): Record<string, FieldInfo>;
10
+ export type FieldInfo = ResourceFieldInfo;
18
11
  /**
19
12
  * Generate TypeScript type declarations for resources.
20
13
  */
21
- export declare function generateResourceTypeScript(resources: Record<string, Record<string, FieldInfo>>, fallbacks: string[], referencedEnums: Set<string>): string;
14
+ export declare function generateResourceTypeScript(resources: Record<string, Record<string, ResourceFieldInfo>>, fallbacks: string[], referencedEnums: Set<string>): string;
22
15
  /**
23
16
  * Generate runtime JavaScript for resources.
24
17
  * Resources are type-only, so this just exports an empty object.
@@ -1 +1 @@
1
- {"version":3,"file":"resources.d.ts","sourceRoot":"","sources":["../../src/generators/resources.ts"],"names":[],"mappings":"AAIA,OAAO,EAKL,KAAK,cAAc,EACpB,MAAM,wBAAwB,CAAC;AAUhC,MAAM,MAAM,wBAAwB,GAAG;IACrC,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;CACnB,CAAC;AAgIF;;GAEG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,MAAM,EACb,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,EACvC,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,GAC7C,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAyF3B;AAED;;GAEG;AACH,wBAAgB,0BAA0B,CACxC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,EACpD,SAAS,EAAE,MAAM,EAAE,EACnB,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,GAC3B,MAAM,CAuCR;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,CAEhD;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,IAAI,CAqEzE"}
1
+ {"version":3,"file":"resources.d.ts","sourceRoot":"","sources":["../../src/generators/resources.ts"],"names":[],"mappings":"AAIA,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,wBAAwB,CAAC;AAUhC,MAAM,MAAM,wBAAwB,GAAG;IACrC,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAGF,MAAM,MAAM,SAAS,GAAG,iBAAiB,CAAC;AAE1C;;GAEG;AACH,wBAAgB,0BAA0B,CACxC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,EAC5D,SAAS,EAAE,MAAM,EAAE,EACnB,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,GAC3B,MAAM,CAuCR;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,CAEhD;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,IAAI,CAoEzE"}
@@ -2,182 +2,9 @@ import ts from 'typescript';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join, parse } from 'node:path';
4
4
  import { getPhpFiles, readFileSafe, writeFileEnsureDir } from '../utils/file.js';
5
- import { extractDocblockArrayShape, extractReturnArrayBlock, parseEnumContent, parseModelCasts, } from '../utils/php-parser.js';
6
- import { mapDocTypeToTs, mapPhpTypeToTs, parseTsObjectStringToPairs } from '../utils/type-mapper.js';
5
+ import { extractDocblockArrayShape, parseResourceFieldsAst, } from '../utils/php-parser.js';
6
+ import { mapDocTypeToTs } from '../utils/type-mapper.js';
7
7
  import { printNode, createTypeAlias, createImportType, parseTypeString, createTypeLiteral, } from '../utils/ts-generator.js';
8
- /**
9
- * Map a PHP cast to a TypeScript type, potentially collecting enum references.
10
- */
11
- function mapCastToType(cast, enumsDir, collectedEnums) {
12
- const original = cast;
13
- // Try to find enum in app/Enums
14
- const match = original.match(/([A-Za-z0-9_\\]+)$/);
15
- const short = match ? match[1].replace(/^\\+/, '') : original;
16
- const enumPath = join(enumsDir, `${short}.php`);
17
- if (existsSync(enumPath)) {
18
- const content = readFileSafe(enumPath);
19
- if (content) {
20
- const def = parseEnumContent(content);
21
- if (def) {
22
- collectedEnums[def.name] = def;
23
- return def.name;
24
- }
25
- }
26
- }
27
- return mapPhpTypeToTs(cast);
28
- }
29
- /**
30
- * Infer TypeScript type from a PHP resource value expression.
31
- */
32
- function inferTypeFromValue(value, key, resourceClass, resourcesDir, modelsDir, enumsDir, collectedEnums) {
33
- let optional = false;
34
- // Resource::collection
35
- const collMatch = value.match(/([A-Za-z0-9_]+)::collection\s*\(\s*(.*?)\s*\)/);
36
- if (collMatch) {
37
- const res = collMatch[1];
38
- const inside = collMatch[2];
39
- if (inside.includes('whenLoaded('))
40
- optional = true;
41
- return { type: `${res}[]`, optional };
42
- }
43
- // whenLoaded
44
- const whenLoadedMatch = value.match(/whenLoaded\(\s*["']([A-Za-z0-9_]+)["']\s*\)/);
45
- if (whenLoadedMatch) {
46
- const name = whenLoadedMatch[1];
47
- optional = true;
48
- const candidate = `${name[0].toUpperCase()}${name.slice(1)}Resource`;
49
- const resPath = join(resourcesDir, `${candidate}.php`);
50
- if (existsSync(resPath)) {
51
- return { type: candidate, optional };
52
- }
53
- return { type: 'Record<string, any>', optional };
54
- }
55
- // $this->resource->property
56
- const propMatch = value.match(/\$this->resource->([A-Za-z0-9_]+)/);
57
- if (propMatch) {
58
- const prop = propMatch[1];
59
- // Boolean checks
60
- if (/\?\s*true\s*:\s*false|===\s*(true|false)|==\s*(true|false)/i.test(value)) {
61
- return { type: 'boolean', optional: false };
62
- }
63
- if (/\$this->resource->(is|has)[A-Za-z0-9_]*\s*\(/i.test(value)) {
64
- return { type: 'boolean', optional: false };
65
- }
66
- const lower = prop.toLowerCase();
67
- if (lower.startsWith('is_') || lower.startsWith('has_') || /^(is|has)[A-Z]/.test(prop)) {
68
- return { type: 'boolean', optional: false };
69
- }
70
- // IDs and UUIDs
71
- if (prop === 'id' || prop.endsWith('_id') || lower === 'uuid') {
72
- return { type: 'string', optional: false };
73
- }
74
- // Check model casts
75
- const modelCandidate = resourceClass.replace(/Resource$/, '');
76
- const modelPath = join(modelsDir, `${modelCandidate}.php`);
77
- if (existsSync(modelPath)) {
78
- const modelContent = readFileSafe(modelPath);
79
- if (modelContent) {
80
- const casts = parseModelCasts(modelContent);
81
- if (casts[prop]) {
82
- const cast = casts[prop];
83
- const trim = cast.trim();
84
- const tsType = trim.startsWith('{') || trim.includes(':') || /array\s*\{/.test(trim)
85
- ? trim
86
- : mapCastToType(cast, enumsDir, collectedEnums);
87
- return { type: tsType, optional: false };
88
- }
89
- }
90
- }
91
- // Number heuristics
92
- if (['last4', 'count', 'total'].includes(prop) || /\d$/.test(prop)) {
93
- return { type: 'number', optional: false };
94
- }
95
- // String heuristics
96
- if (['id', 'uuid', 'slug', 'name', 'repository', 'region', 'email'].includes(prop)) {
97
- return { type: 'string', optional: false };
98
- }
99
- // Timestamps
100
- if (prop.endsWith('_at') || ['created_at', 'updated_at', 'lastActive'].includes(prop)) {
101
- return { type: 'string', optional: false };
102
- }
103
- return { type: 'string', optional: false };
104
- }
105
- return { type: 'any', optional: false };
106
- }
107
- /**
108
- * Parse fields from a PHP array block (from toArray() method).
109
- */
110
- export function parseFieldsFromArrayBlock(block, resourceClass, docShape, resourcesDir, modelsDir, enumsDir, collectedEnums) {
111
- const lines = block.split(/\r?\n/);
112
- const fields = {};
113
- for (let i = 0; i < lines.length; i++) {
114
- const line = lines[i].trim();
115
- if (!line || line.startsWith('//'))
116
- continue;
117
- const match = line.match(/["'](?<key>[A-Za-z0-9_]+)["']\s*=>\s*(?<value>.*?)(?:,\s*$|$)/);
118
- if (!match || !match.groups)
119
- continue;
120
- const key = match.groups.key;
121
- let value = match.groups.value.trim();
122
- // Boolean heuristic
123
- const lowerKey = key.toLowerCase();
124
- if (lowerKey.startsWith('is_') || lowerKey.startsWith('has_') || /^(is|has)[A-Z]/.test(key)) {
125
- fields[key] = { type: 'boolean', optional: false };
126
- continue;
127
- }
128
- // Handle nested arrays
129
- if (value.startsWith('[')) {
130
- let bracketDepth = (value.match(/\[/g) || []).length - (value.match(/\]/g) || []).length;
131
- const innerLines = [];
132
- const rest = value.replace(/^\[\s*/, '');
133
- if (rest)
134
- innerLines.push(rest);
135
- let j = i + 1;
136
- while (j < lines.length && bracketDepth > 0) {
137
- const l = lines[j];
138
- bracketDepth += (l.match(/\[/g) || []).length - (l.match(/\]/g) || []).length;
139
- innerLines.push(l.trim());
140
- j++;
141
- }
142
- i = j - 1;
143
- const innerBlock = innerLines.join('\n');
144
- const nested = parseFieldsFromArrayBlock(innerBlock, resourceClass, docShape, resourcesDir, modelsDir, enumsDir, collectedEnums);
145
- // Apply docblock shape if available
146
- if (docShape && docShape[key]) {
147
- const docType = docShape[key].trim();
148
- if (docType.startsWith('{')) {
149
- const docInner = parseTsObjectStringToPairs(docType);
150
- for (const dk of Object.keys(docInner)) {
151
- nested[dk] = { type: docInner[dk], optional: false };
152
- }
153
- }
154
- }
155
- const props = [];
156
- for (const nkey of Object.keys(nested)) {
157
- const ninfo = nested[nkey];
158
- const ntype = ninfo.type || 'any';
159
- const nopt = ninfo.optional ? '?' : '';
160
- props.push(`${nkey}${nopt}: ${ntype}`);
161
- }
162
- const inline = `{ ${props.join('; ')} }`;
163
- fields[key] = { type: inline, optional: false };
164
- continue;
165
- }
166
- // Use docblock type if available
167
- if (docShape && docShape[key]) {
168
- fields[key] = { type: docShape[key], optional: false };
169
- continue;
170
- }
171
- // Infer type from value
172
- const info = inferTypeFromValue(value, key, resourceClass, resourcesDir, modelsDir, enumsDir, collectedEnums);
173
- if (docShape && docShape[key] && (!info.type || info.type === 'any')) {
174
- info.type = docShape[key];
175
- info.optional = info.optional ?? false;
176
- }
177
- fields[key] = info;
178
- }
179
- return fields;
180
- }
181
8
  /**
182
9
  * Generate TypeScript type declarations for resources.
183
10
  */
@@ -241,13 +68,19 @@ export function generateResources(options) {
241
68
  const content = readFileSafe(filePath) || '';
242
69
  const className = parse(file).name;
243
70
  const docShape = extractDocblockArrayShape(content);
244
- const arrayBlock = extractReturnArrayBlock(content);
245
- if (!arrayBlock) {
71
+ const mappedDocShape = docShape ? mapDocTypeToTsForShape(docShape) : null;
72
+ const fields = parseResourceFieldsAst(content, {
73
+ resourcesDir,
74
+ modelsDir,
75
+ enumsDir,
76
+ docShape: mappedDocShape,
77
+ collectedEnums,
78
+ });
79
+ if (!fields) {
246
80
  fallbacks.push(className);
247
81
  resources[className] = {};
248
82
  }
249
83
  else {
250
- const fields = parseFieldsFromArrayBlock(arrayBlock, className, docShape ? mapDocTypeToTsForShape(docShape) : null, resourcesDir, modelsDir, enumsDir, collectedEnums);
251
84
  resources[className] = fields;
252
85
  }
253
86
  }
@@ -23,9 +23,26 @@ export declare function parseModelCasts(phpContent: string): Record<string, stri
23
23
  * This is a pure function that takes PHP source code as input.
24
24
  */
25
25
  export declare function extractDocblockArrayShape(phpContent: string): Record<string, string> | null;
26
+ export type ResourceFieldInfo = {
27
+ type: string;
28
+ optional: boolean;
29
+ };
30
+ export type ResourceArrayEntry = {
31
+ key: string;
32
+ fieldInfo: ResourceFieldInfo;
33
+ nested?: Record<string, ResourceArrayEntry>;
34
+ };
35
+ export type ParseResourceOptions = {
36
+ resourcesDir?: string;
37
+ modelsDir?: string;
38
+ enumsDir?: string;
39
+ docShape?: Record<string, string> | null;
40
+ collectedEnums?: Record<string, EnumDefinition>;
41
+ resourceClass?: string;
42
+ };
26
43
  /**
27
- * Extract the return array block from a toArray() method in PHP content.
28
- * This is a pure function that takes PHP source code as input.
44
+ * Parse resource fields from PHP content using AST.
45
+ * Returns null if parsing fails or no toArray method is found.
29
46
  */
30
- export declare function extractReturnArrayBlock(phpContent: string): string | null;
47
+ export declare function parseResourceFieldsAst(phpContent: string, options?: Omit<ParseResourceOptions, 'resourceClass'>): Record<string, ResourceFieldInfo> | null;
31
48
  //# sourceMappingURL=php-parser.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"php-parser.d.ts","sourceRoot":"","sources":["../../src/utils/php-parser.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,KAAK,EAAE,QAAQ,EAAE,CAAC;CACnB,CAAC;AA6FF;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAsF1E;AA4CD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAwC1E;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAwE3F;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CASzE"}
1
+ {"version":3,"file":"php-parser.d.ts","sourceRoot":"","sources":["../../src/utils/php-parser.ts"],"names":[],"mappings":"AAqBA,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,KAAK,EAAE,QAAQ,EAAE,CAAC;CACnB,CAAC;AA6FF;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAsF1E;AA4CD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAwC1E;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAwE3F;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,iBAAiB,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;CAC7C,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;IACzC,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAChD,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AA8RF;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,IAAI,CAAC,oBAAoB,EAAE,eAAe,CAAM,GACxD,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,GAAG,IAAI,CAqC1C"}
@@ -1,3 +1,7 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readFileSafe } from './file.js';
4
+ import { mapPhpTypeToTs } from './type-mapper.js';
1
5
  // Import php-parser (CommonJS module with constructor)
2
6
  // eslint-disable-next-line @typescript-eslint/no-require-imports
3
7
  const PhpParser = require('php-parser');
@@ -339,16 +343,289 @@ export function extractDocblockArrayShape(phpContent) {
339
343
  return pairs;
340
344
  }
341
345
  /**
342
- * Extract the return array block from a toArray() method in PHP content.
343
- * This is a pure function that takes PHP source code as input.
346
+ * Check if an AST node contains a whenLoaded call.
344
347
  */
345
- export function extractReturnArrayBlock(phpContent) {
346
- const match = phpContent.match(/function\s+toArray\s*\([^)]*\)\s*:\s*array\s*\{([\s\S]*?)\n\s*\}/);
347
- if (!match)
348
+ function containsWhenLoaded(node) {
349
+ if (node.kind === 'call') {
350
+ const call = node;
351
+ if (call.what.kind === 'propertylookup') {
352
+ const lookup = call.what;
353
+ const offset = lookup.offset;
354
+ const name = offset.kind === 'identifier' ? offset.name : null;
355
+ if (name === 'whenLoaded')
356
+ return true;
357
+ }
358
+ }
359
+ // Check arguments recursively
360
+ const obj = node;
361
+ for (const key of Object.keys(obj)) {
362
+ const val = obj[key];
363
+ if (val && typeof val === 'object') {
364
+ if (val.kind && containsWhenLoaded(val))
365
+ return true;
366
+ if (Array.isArray(val)) {
367
+ for (const item of val) {
368
+ if (item && item.kind && containsWhenLoaded(item))
369
+ return true;
370
+ }
371
+ }
372
+ }
373
+ }
374
+ return false;
375
+ }
376
+ /**
377
+ * Extract resource name from a static call like Resource::make() or Resource::collection().
378
+ */
379
+ function extractStaticCallResource(call) {
380
+ if (call.what.kind !== 'staticlookup')
348
381
  return null;
349
- const body = match[1];
350
- const returnMatch = body.match(/return\s*\[\s*([\s\S]*?)\s*\];/);
351
- if (!returnMatch)
382
+ const lookup = call.what;
383
+ if (lookup.what.kind !== 'name')
352
384
  return null;
353
- return returnMatch[1];
385
+ const resource = lookup.what.name;
386
+ const offset = lookup.offset;
387
+ const method = offset.kind === 'identifier' ? offset.name : null;
388
+ if (!method)
389
+ return null;
390
+ return { resource, method };
391
+ }
392
+ /**
393
+ * Extract resource name from a new expression like new Resource().
394
+ */
395
+ function extractNewResource(newExpr) {
396
+ if (newExpr.what.kind !== 'name')
397
+ return null;
398
+ return newExpr.what.name;
399
+ }
400
+ /**
401
+ * Extract property name from $this->resource->property.
402
+ */
403
+ function extractResourceProperty(node) {
404
+ if (node.kind !== 'propertylookup')
405
+ return null;
406
+ const lookup = node;
407
+ const what = lookup.what;
408
+ // Check for $this->resource
409
+ if (what.kind === 'propertylookup') {
410
+ const inner = what;
411
+ if (inner.what.kind === 'variable' && inner.what.name === 'this') {
412
+ const innerOffset = inner.offset;
413
+ const innerName = innerOffset.kind === 'identifier' ? innerOffset.name : null;
414
+ if (innerName === 'resource') {
415
+ const offset = lookup.offset;
416
+ return offset.kind === 'identifier' ? offset.name : null;
417
+ }
418
+ }
419
+ }
420
+ return null;
421
+ }
422
+ /**
423
+ * Map a PHP cast to a TypeScript type, potentially collecting enum references.
424
+ */
425
+ function mapCastToType(cast, enumsDir, collectedEnums) {
426
+ const original = cast;
427
+ // Try to find enum in app/Enums
428
+ const match = original.match(/([A-Za-z0-9_\\]+)$/);
429
+ const short = match ? match[1].replace(/^\\+/, '') : original;
430
+ const enumPath = join(enumsDir, `${short}.php`);
431
+ if (existsSync(enumPath)) {
432
+ const content = readFileSafe(enumPath);
433
+ if (content) {
434
+ const def = parseEnumContent(content);
435
+ if (def) {
436
+ collectedEnums[def.name] = def;
437
+ return def.name;
438
+ }
439
+ }
440
+ }
441
+ return mapPhpTypeToTs(cast);
442
+ }
443
+ /**
444
+ * Check if a resource file exists.
445
+ */
446
+ function resourceExists(resourceName, resourcesDir) {
447
+ if (!resourcesDir)
448
+ return true; // Trust the name if no dir provided
449
+ return existsSync(join(resourcesDir, `${resourceName}.php`));
450
+ }
451
+ /**
452
+ * Infer TypeScript type from an AST value node.
453
+ */
454
+ function inferTypeFromAstNode(node, key, options = {}) {
455
+ const { resourcesDir, modelsDir, enumsDir, docShape, collectedEnums = {}, resourceClass = '' } = options;
456
+ const optional = containsWhenLoaded(node);
457
+ // Use docblock type if available
458
+ if (docShape && docShape[key]) {
459
+ return { type: docShape[key], optional };
460
+ }
461
+ // Boolean heuristics from key name
462
+ const lowerKey = key.toLowerCase();
463
+ if (lowerKey.startsWith('is_') || lowerKey.startsWith('has_') || /^(is|has)[A-Z]/.test(key)) {
464
+ return { type: 'boolean', optional };
465
+ }
466
+ // Handle static calls: Resource::collection() or Resource::make()
467
+ if (node.kind === 'call') {
468
+ const call = node;
469
+ const staticInfo = extractStaticCallResource(call);
470
+ if (staticInfo) {
471
+ const { resource, method } = staticInfo;
472
+ // Collection or Collection::make returns any[]
473
+ if (resource === 'Collection') {
474
+ return { type: 'any[]', optional };
475
+ }
476
+ // Resource::collection or Resource::make returns Resource[]
477
+ if (method === 'collection' || method === 'make') {
478
+ if (resourceExists(resource, resourcesDir)) {
479
+ return { type: `${resource}[]`, optional };
480
+ }
481
+ return { type: 'any[]', optional };
482
+ }
483
+ }
484
+ // Check if it's a whenLoaded call without a wrapper resource
485
+ if (call.what.kind === 'propertylookup') {
486
+ const lookup = call.what;
487
+ const offset = lookup.offset;
488
+ const name = offset.kind === 'identifier' ? offset.name : null;
489
+ if (name === 'whenLoaded') {
490
+ // Try to find matching resource (only if resourcesDir is provided)
491
+ if (resourcesDir) {
492
+ const args = call.arguments;
493
+ if (args.length > 0 && args[0].kind === 'string') {
494
+ const relationName = args[0].value;
495
+ const candidate = `${relationName[0].toUpperCase()}${relationName.slice(1)}Resource`;
496
+ if (existsSync(join(resourcesDir, `${candidate}.php`))) {
497
+ return { type: candidate, optional: true };
498
+ }
499
+ }
500
+ }
501
+ return { type: 'Record<string, any>', optional: true };
502
+ }
503
+ }
504
+ }
505
+ // Handle new Resource()
506
+ if (node.kind === 'new') {
507
+ const newExpr = node;
508
+ const resource = extractNewResource(newExpr);
509
+ if (resource) {
510
+ if (resourceExists(resource, resourcesDir)) {
511
+ return { type: resource, optional };
512
+ }
513
+ return { type: 'any', optional };
514
+ }
515
+ return { type: 'any', optional };
516
+ }
517
+ // Handle $this->resource->property
518
+ const prop = extractResourceProperty(node);
519
+ if (prop) {
520
+ const lower = prop.toLowerCase();
521
+ // Boolean checks
522
+ if (lower.startsWith('is_') || lower.startsWith('has_') || /^(is|has)[A-Z]/.test(prop)) {
523
+ return { type: 'boolean', optional: false };
524
+ }
525
+ // IDs and UUIDs
526
+ if (prop === 'id' || prop.endsWith('_id') || lower === 'uuid' || prop.endsWith('Id')) {
527
+ return { type: 'string', optional: false };
528
+ }
529
+ // Check model casts
530
+ if (modelsDir && resourceClass) {
531
+ const modelCandidate = resourceClass.replace(/Resource$/, '');
532
+ const modelPath = join(modelsDir, `${modelCandidate}.php`);
533
+ if (existsSync(modelPath)) {
534
+ const modelContent = readFileSafe(modelPath);
535
+ if (modelContent) {
536
+ const casts = parseModelCasts(modelContent);
537
+ if (casts[prop]) {
538
+ const cast = casts[prop];
539
+ const trim = cast.trim();
540
+ const tsType = trim.startsWith('{') || trim.includes(':') || /array\s*\{/.test(trim)
541
+ ? trim
542
+ : mapCastToType(cast, enumsDir || '', collectedEnums);
543
+ return { type: tsType, optional: false };
544
+ }
545
+ }
546
+ }
547
+ }
548
+ // Timestamps
549
+ if (prop.endsWith('_at') || prop.endsWith('At')) {
550
+ return { type: 'string', optional: false };
551
+ }
552
+ return { type: 'string', optional: false };
553
+ }
554
+ // Handle nested arrays
555
+ if (node.kind === 'array') {
556
+ const arrayNode = node;
557
+ const nestedFields = parseArrayEntries(arrayNode.items, options);
558
+ if (Object.keys(nestedFields).length > 0) {
559
+ const props = Object.entries(nestedFields).map(([k, v]) => {
560
+ const opt = v.fieldInfo.optional ? '?' : '';
561
+ return `${k}${opt}: ${v.fieldInfo.type}`;
562
+ });
563
+ return { type: `{ ${props.join('; ')} }`, optional };
564
+ }
565
+ return { type: 'any[]', optional };
566
+ }
567
+ return { type: 'any', optional };
568
+ }
569
+ /**
570
+ * Parse array entries from AST array items.
571
+ */
572
+ function parseArrayEntries(items, options = {}) {
573
+ const result = {};
574
+ for (const item of items) {
575
+ if (item.kind !== 'entry')
576
+ continue;
577
+ const entry = item;
578
+ if (!entry.key)
579
+ continue;
580
+ const key = getStringValue(entry.key);
581
+ if (!key)
582
+ continue;
583
+ const fieldInfo = inferTypeFromAstNode(entry.value, key, options);
584
+ result[key] = { key, fieldInfo };
585
+ // Handle nested arrays
586
+ if (entry.value.kind === 'array') {
587
+ const nested = parseArrayEntries(entry.value.items, options);
588
+ if (Object.keys(nested).length > 0) {
589
+ result[key].nested = nested;
590
+ }
591
+ }
592
+ }
593
+ return result;
594
+ }
595
+ /**
596
+ * Parse resource fields from PHP content using AST.
597
+ * Returns null if parsing fails or no toArray method is found.
598
+ */
599
+ export function parseResourceFieldsAst(phpContent, options = {}) {
600
+ const ast = parsePhp(phpContent);
601
+ if (!ast)
602
+ return null;
603
+ // Find the class
604
+ const classNode = findNodeByKind(ast, 'class');
605
+ if (!classNode)
606
+ return null;
607
+ // Extract class name for model cast lookups
608
+ const className = typeof classNode.name === 'string'
609
+ ? classNode.name
610
+ : classNode.name.name;
611
+ // Find toArray method
612
+ const methods = findAllNodesByKind(classNode, 'method');
613
+ const toArrayMethod = methods.find((m) => {
614
+ const methodName = typeof m.name === 'string' ? m.name : m.name.name;
615
+ return methodName === 'toArray';
616
+ });
617
+ if (!toArrayMethod || !toArrayMethod.body)
618
+ return null;
619
+ // Find return statement with array
620
+ const returnNode = findNodeByKind(toArrayMethod.body, 'return');
621
+ if (!returnNode || !returnNode.expr || returnNode.expr.kind !== 'array')
622
+ return null;
623
+ const arrayNode = returnNode.expr;
624
+ const entries = parseArrayEntries(arrayNode.items, { ...options, resourceClass: className });
625
+ // Convert to flat field info
626
+ const result = {};
627
+ for (const [key, entry] of Object.entries(entries)) {
628
+ result[key] = entry.fieldInfo;
629
+ }
630
+ return result;
354
631
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "prettier": "@aniftyco/prettier",
4
4
  "description": "Ferries Laravel types to your TypeScript frontend",
5
5
  "type": "module",
6
- "version": "0.1.3",
6
+ "version": "0.1.4",
7
7
  "repository": "https://github.com/aniftyco/vite-plugin-ferry",
8
8
  "main": "dist/index.js",
9
9
  "types": "dist/index.d.ts",