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 +174 -4
- package/dist/generators/resources.d.ts +3 -10
- package/dist/generators/resources.d.ts.map +1 -1
- package/dist/generators/resources.js +11 -178
- package/dist/utils/php-parser.d.ts +20 -3
- package/dist/utils/php-parser.d.ts.map +1 -1
- package/dist/utils/php-parser.js +286 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,7 +44,9 @@ import type { UserResource } from '@ferry/resources';
|
|
|
44
44
|
|
|
45
45
|
### Enums
|
|
46
46
|
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
6
|
-
import { mapDocTypeToTs
|
|
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
|
|
245
|
-
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
|
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":"
|
|
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"}
|
package/dist/utils/php-parser.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
350
|
-
|
|
351
|
-
if (!returnMatch)
|
|
382
|
+
const lookup = call.what;
|
|
383
|
+
if (lookup.what.kind !== 'name')
|
|
352
384
|
return null;
|
|
353
|
-
|
|
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.
|
|
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",
|