vite-plugin-ferry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NiftyCo, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # vite-plugin-ferry
2
+
3
+ > A Vite plugin that ferries your Laravel backend types to the frontend as fully-typed TypeScript.
4
+
5
+ ## What it does
6
+
7
+ Ferry watches your Laravel application and automatically generates TypeScript definitions so your frontend always stays
8
+ in sync with your backend.
9
+
10
+ - **Enums** — Generates types and runtime constants from `app/Enums/`
11
+ - **Resources** — Generates response types from `app/Http/Resources/`
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install vite-plugin-ferry --save-dev
17
+ ```
18
+
19
+ Add it to your `vite.config.ts`:
20
+
21
+ ```ts
22
+ import { defineConfig } from 'vite';
23
+ import ferry from 'vite-plugin-ferry';
24
+
25
+ export default defineConfig({
26
+ plugins: [ferry()],
27
+ });
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ Import your backend types directly in your frontend code:
33
+
34
+ ```ts
35
+ import { OrderStatus } from '@ferry/enums';
36
+ import { UserResource } from '@ferry/resources';
37
+ ```
38
+
39
+ ## License
40
+
41
+ See [LICENSE](LICENSE) for details.
@@ -0,0 +1,23 @@
1
+ import { type EnumDefinition } from '../utils/php-parser.js';
2
+ export type EnumGeneratorOptions = {
3
+ enumsDir: string;
4
+ outputDir: string;
5
+ packageName: string;
6
+ };
7
+ /**
8
+ * Generate TypeScript type declarations for enums.
9
+ */
10
+ export declare function generateEnumTypeScript(enums: Record<string, EnumDefinition>): string;
11
+ /**
12
+ * Generate runtime JavaScript for enums.
13
+ */
14
+ export declare function generateEnumRuntime(enums: Record<string, EnumDefinition>): string;
15
+ /**
16
+ * Collect all enum definitions from the enums directory.
17
+ */
18
+ export declare function collectEnums(enumsDir: string): Record<string, EnumDefinition>;
19
+ /**
20
+ * Generate enum files (TypeScript declarations and runtime JavaScript).
21
+ */
22
+ export declare function generateEnums(options: EnumGeneratorOptions): void;
23
+ //# sourceMappingURL=enums.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"enums.d.ts","sourceRoot":"","sources":["../../src/generators/enums.ts"],"names":[],"mappings":"AAGA,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE5E,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,GAAG,MAAM,CAyCpF;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,GAAG,MAAM,CAyBjF;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAuB7E;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,IAAI,CA6BjE"}
@@ -0,0 +1,125 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getPhpFiles, writeFileEnsureDir } from '../utils/file.js';
4
+ import { parseEnumFile } from '../utils/php-parser.js';
5
+ /**
6
+ * Generate TypeScript type declarations for enums.
7
+ */
8
+ export function generateEnumTypeScript(enums) {
9
+ const lines = [];
10
+ lines.push('// This file is auto-generated by the primcloud Vite plugin.');
11
+ lines.push('// Do not edit directly.');
12
+ lines.push('');
13
+ for (const enumName of Object.keys(enums)) {
14
+ const enumDef = enums[enumName];
15
+ const hasLabels = enumDef.cases.some((c) => c.label);
16
+ if (hasLabels) {
17
+ // Generate a const object with typed properties for enums with labels
18
+ lines.push(`export declare const ${enumDef.name}: {`);
19
+ for (const c of enumDef.cases) {
20
+ const val = String(c.value).replace(/'/g, "\\'");
21
+ const label = c.label ? String(c.label).replace(/'/g, "\\'") : val;
22
+ lines.push(` ${c.key}: { value: '${val}'; label: '${label}' };`);
23
+ }
24
+ lines.push('};');
25
+ lines.push('');
26
+ }
27
+ else {
28
+ // Generate a traditional enum for enums without labels
29
+ lines.push(`export enum ${enumDef.name} {`);
30
+ for (const c of enumDef.cases) {
31
+ const val = c.value;
32
+ if (enumDef.backing === 'int' || enumDef.backing === 'integer') {
33
+ if (!isNaN(Number(val))) {
34
+ lines.push(` ${c.key} = ${val},`);
35
+ }
36
+ else {
37
+ lines.push(` ${c.key} = '${val}',`);
38
+ }
39
+ }
40
+ else {
41
+ lines.push(` ${c.key} = '${String(val).replace(/'/g, "\\'")}',`);
42
+ }
43
+ }
44
+ lines.push('}');
45
+ lines.push('');
46
+ }
47
+ }
48
+ return lines.join('\n');
49
+ }
50
+ /**
51
+ * Generate runtime JavaScript for enums.
52
+ */
53
+ export function generateEnumRuntime(enums) {
54
+ const lines = [];
55
+ lines.push('// Auto-generated by primcloud Vite plugin');
56
+ lines.push('');
57
+ for (const enumName of Object.keys(enums)) {
58
+ const enumDef = enums[enumName];
59
+ const hasLabels = enumDef.cases.some((c) => c.label);
60
+ lines.push(`export const ${enumDef.name} = {`);
61
+ for (const c of enumDef.cases) {
62
+ const val = String(c.value).replace(/'/g, "\\'");
63
+ if (hasLabels) {
64
+ const label = c.label ? String(c.label).replace(/'/g, "\\'") : val;
65
+ lines.push(` ${c.key}: { value: '${val}', label: '${label}' },`);
66
+ }
67
+ else {
68
+ lines.push(` ${c.key}: '${val}',`);
69
+ }
70
+ }
71
+ lines.push('};');
72
+ lines.push('');
73
+ }
74
+ lines.push('export default {};');
75
+ return lines.join('\n');
76
+ }
77
+ /**
78
+ * Collect all enum definitions from the enums directory.
79
+ */
80
+ export function collectEnums(enumsDir) {
81
+ const enums = {};
82
+ if (!existsSync(enumsDir)) {
83
+ return enums;
84
+ }
85
+ const enumFiles = getPhpFiles(enumsDir);
86
+ for (const file of enumFiles) {
87
+ try {
88
+ const enumPath = join(enumsDir, file);
89
+ const def = parseEnumFile(enumPath);
90
+ if (def) {
91
+ enums[def.name] = def;
92
+ }
93
+ }
94
+ catch (e) {
95
+ // Ignore parse errors
96
+ console.warn(`Failed to parse enum file: ${file}`, e);
97
+ }
98
+ }
99
+ return enums;
100
+ }
101
+ /**
102
+ * Generate enum files (TypeScript declarations and runtime JavaScript).
103
+ */
104
+ export function generateEnums(options) {
105
+ const { enumsDir, outputDir, packageName } = options;
106
+ // Collect all enums
107
+ const enums = collectEnums(enumsDir);
108
+ // Generate TypeScript declarations
109
+ const dtsContent = generateEnumTypeScript(enums);
110
+ const dtsPath = join(outputDir, 'index.d.ts');
111
+ writeFileEnsureDir(dtsPath, dtsContent);
112
+ // Generate runtime JavaScript
113
+ const jsContent = generateEnumRuntime(enums);
114
+ const jsPath = join(outputDir, 'index.js');
115
+ writeFileEnsureDir(jsPath, jsContent);
116
+ // Generate package.json
117
+ const pkgJson = JSON.stringify({
118
+ name: packageName,
119
+ version: '0.0.0',
120
+ main: 'index.js',
121
+ types: 'index.d.ts',
122
+ }, null, 2);
123
+ const pkgPath = join(outputDir, 'package.json');
124
+ writeFileEnsureDir(pkgPath, pkgJson);
125
+ }
@@ -0,0 +1,26 @@
1
+ export type ResourceGeneratorOptions = {
2
+ resourcesDir: string;
3
+ enumsDir: string;
4
+ modelsDir: string;
5
+ outputDir: string;
6
+ packageName: string;
7
+ };
8
+ type FieldInfo = {
9
+ type: string;
10
+ optional: boolean;
11
+ };
12
+ /**
13
+ * Generate TypeScript type declarations for resources.
14
+ */
15
+ export declare function generateResourceTypeScript(resources: Record<string, Record<string, FieldInfo>>, fallbacks: string[], referencedEnums: Set<string>): string;
16
+ /**
17
+ * Generate runtime JavaScript for resources.
18
+ * Resources are type-only, so this just exports an empty object.
19
+ */
20
+ export declare function generateResourceRuntime(): string;
21
+ /**
22
+ * Generate resource type files (TypeScript declarations and runtime JavaScript).
23
+ */
24
+ export declare function generateResources(options: ResourceGeneratorOptions): void;
25
+ export {};
26
+ //# sourceMappingURL=resources.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resources.d.ts","sourceRoot":"","sources":["../../src/generators/resources.ts"],"names":[],"mappings":"AAYA,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;CACrB,CAAC;AAEF,KAAK,SAAS,GAAG;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;CACnB,CAAC;AAiOF;;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,CAmCR;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,CAOhD;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,IAAI,CAqEzE"}
@@ -0,0 +1,282 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, parse } from 'node:path';
3
+ import { getPhpFiles, readFileSafe, writeFileEnsureDir } from '../utils/file.js';
4
+ import { extractDocblockArrayShape, extractReturnArrayBlock, getModelCasts, parseEnumFile, } from '../utils/php-parser.js';
5
+ import { mapDocTypeToTs, mapPhpTypeToTs, parseTsObjectStringToPairs } from '../utils/type-mapper.js';
6
+ /**
7
+ * Map a PHP cast to a TypeScript type, potentially collecting enum references.
8
+ */
9
+ function mapCastToType(cast, enumsDir, collectedEnums) {
10
+ const original = cast;
11
+ const lower = cast.toLowerCase();
12
+ // Try to find enum in app/Enums
13
+ const match = original.match(/([A-Za-z0-9_\\]+)$/);
14
+ const short = match ? match[1].replace(/^\\+/, '') : original;
15
+ const enumPath = join(enumsDir, `${short}.php`);
16
+ if (existsSync(enumPath)) {
17
+ const def = parseEnumFile(enumPath);
18
+ if (def) {
19
+ collectedEnums[def.name] = def;
20
+ return def.name;
21
+ }
22
+ }
23
+ return mapPhpTypeToTs(cast);
24
+ }
25
+ /**
26
+ * Infer TypeScript type from a PHP resource value expression.
27
+ */
28
+ function inferTypeFromValue(value, key, resourceClass, resourcesDir, modelsDir, enumsDir, collectedEnums) {
29
+ let optional = false;
30
+ // Resource::collection
31
+ const collMatch = value.match(/([A-Za-z0-9_]+)::collection\s*\(\s*(.*?)\s*\)/);
32
+ if (collMatch) {
33
+ const res = collMatch[1];
34
+ const inside = collMatch[2];
35
+ if (inside.includes('whenLoaded('))
36
+ optional = true;
37
+ return { type: `${res}[]`, optional };
38
+ }
39
+ // whenLoaded
40
+ const whenLoadedMatch = value.match(/whenLoaded\(\s*["']([A-Za-z0-9_]+)["']\s*\)/);
41
+ if (whenLoadedMatch) {
42
+ const name = whenLoadedMatch[1];
43
+ optional = true;
44
+ const candidate = `${name[0].toUpperCase()}${name.slice(1)}Resource`;
45
+ const resPath = join(resourcesDir, `${candidate}.php`);
46
+ if (existsSync(resPath)) {
47
+ return { type: candidate, optional };
48
+ }
49
+ return { type: 'Record<string, any>', optional };
50
+ }
51
+ // $this->resource->property
52
+ const propMatch = value.match(/\$this->resource->([A-Za-z0-9_]+)/);
53
+ if (propMatch) {
54
+ const prop = propMatch[1];
55
+ // Boolean checks
56
+ if (/\?\s*true\s*:\s*false|===\s*(true|false)|==\s*(true|false)/i.test(value)) {
57
+ return { type: 'boolean', optional: false };
58
+ }
59
+ if (/\$this->resource->(is|has)[A-Za-z0-9_]*\s*\(/i.test(value)) {
60
+ return { type: 'boolean', optional: false };
61
+ }
62
+ const lower = prop.toLowerCase();
63
+ if (lower.startsWith('is_') || lower.startsWith('has_') || /^(is|has)[A-Z]/.test(prop)) {
64
+ return { type: 'boolean', optional: false };
65
+ }
66
+ // IDs and UUIDs
67
+ if (prop === 'id' || prop.endsWith('_id') || lower === 'uuid') {
68
+ return { type: 'string', optional: false };
69
+ }
70
+ // Check model casts
71
+ const modelCandidate = resourceClass.replace(/Resource$/, '');
72
+ const modelPath = join(modelsDir, `${modelCandidate}.php`);
73
+ if (existsSync(modelPath)) {
74
+ const casts = getModelCasts(modelPath);
75
+ if (casts[prop]) {
76
+ const cast = casts[prop];
77
+ const trim = cast.trim();
78
+ const tsType = trim.startsWith('{') || trim.includes(':') || /array\s*\{/.test(trim)
79
+ ? trim
80
+ : mapCastToType(cast, enumsDir, collectedEnums);
81
+ return { type: tsType, optional: false };
82
+ }
83
+ }
84
+ // Number heuristics
85
+ if (['last4', 'count', 'total'].includes(prop) || /\d$/.test(prop)) {
86
+ return { type: 'number', optional: false };
87
+ }
88
+ // String heuristics
89
+ if (['id', 'uuid', 'slug', 'name', 'repository', 'region', 'email'].includes(prop)) {
90
+ return { type: 'string', optional: false };
91
+ }
92
+ // Timestamps
93
+ if (prop.endsWith('_at') || ['created_at', 'updated_at', 'lastActive'].includes(prop)) {
94
+ return { type: 'string', optional: false };
95
+ }
96
+ return { type: 'string', optional: false };
97
+ }
98
+ return { type: 'any', optional: false };
99
+ }
100
+ /**
101
+ * Parse fields from a PHP array block (from toArray() method).
102
+ */
103
+ function parseFieldsFromArrayBlock(block, resourceClass, docShape, resourcesDir, modelsDir, enumsDir, collectedEnums) {
104
+ const lines = block.split(/\r?\n/);
105
+ const fields = {};
106
+ for (let i = 0; i < lines.length; i++) {
107
+ const line = lines[i].trim();
108
+ if (!line || line.startsWith('//'))
109
+ continue;
110
+ const match = line.match(/["'](?<key>[A-Za-z0-9_]+)["']\s*=>\s*(?<value>.*?)(?:,\s*$|$)/);
111
+ if (!match || !match.groups)
112
+ continue;
113
+ const key = match.groups.key;
114
+ let value = match.groups.value.trim();
115
+ // Boolean heuristic
116
+ const lowerKey = key.toLowerCase();
117
+ if (lowerKey.startsWith('is_') || lowerKey.startsWith('has_') || /^(is|has)[A-Z]/.test(key)) {
118
+ fields[key] = { type: 'boolean', optional: false };
119
+ continue;
120
+ }
121
+ // Handle nested arrays
122
+ if (value.startsWith('[')) {
123
+ let bracketDepth = (value.match(/\[/g) || []).length - (value.match(/\]/g) || []).length;
124
+ const innerLines = [];
125
+ const rest = value.replace(/^\[\s*/, '');
126
+ if (rest)
127
+ innerLines.push(rest);
128
+ let j = i + 1;
129
+ while (j < lines.length && bracketDepth > 0) {
130
+ const l = lines[j];
131
+ bracketDepth += (l.match(/\[/g) || []).length - (l.match(/\]/g) || []).length;
132
+ innerLines.push(l.trim());
133
+ j++;
134
+ }
135
+ i = j - 1;
136
+ const innerBlock = innerLines.join('\n');
137
+ const nested = parseFieldsFromArrayBlock(innerBlock, resourceClass, docShape, resourcesDir, modelsDir, enumsDir, collectedEnums);
138
+ // Apply docblock shape if available
139
+ if (docShape && docShape[key]) {
140
+ const docType = docShape[key].trim();
141
+ if (docType.startsWith('{')) {
142
+ const docInner = parseTsObjectStringToPairs(docType);
143
+ for (const dk of Object.keys(docInner)) {
144
+ nested[dk] = { type: docInner[dk], optional: false };
145
+ }
146
+ }
147
+ }
148
+ const props = [];
149
+ for (const nkey of Object.keys(nested)) {
150
+ const ninfo = nested[nkey];
151
+ const ntype = ninfo.type || 'any';
152
+ const nopt = ninfo.optional ? '?' : '';
153
+ props.push(`${nkey}${nopt}: ${ntype}`);
154
+ }
155
+ const inline = `{ ${props.join('; ')} }`;
156
+ fields[key] = { type: inline, optional: false };
157
+ continue;
158
+ }
159
+ // Use docblock type if available
160
+ if (docShape && docShape[key]) {
161
+ fields[key] = { type: docShape[key], optional: false };
162
+ continue;
163
+ }
164
+ // Infer type from value
165
+ const info = inferTypeFromValue(value, key, resourceClass, resourcesDir, modelsDir, enumsDir, collectedEnums);
166
+ if (docShape && docShape[key] && (!info.type || info.type === 'any')) {
167
+ info.type = docShape[key];
168
+ info.optional = info.optional ?? false;
169
+ }
170
+ fields[key] = info;
171
+ }
172
+ return fields;
173
+ }
174
+ /**
175
+ * Generate TypeScript type declarations for resources.
176
+ */
177
+ export function generateResourceTypeScript(resources, fallbacks, referencedEnums) {
178
+ const lines = [];
179
+ lines.push('// This file is auto-generated by the primcloud Vite plugin.');
180
+ lines.push('// Do not edit directly.');
181
+ lines.push('');
182
+ // Import referenced enums from @app/enums
183
+ if (referencedEnums.size > 0) {
184
+ const enumImports = Array.from(referencedEnums).sort().join(', ');
185
+ lines.push(`import type { ${enumImports} } from '@app/enums';`);
186
+ lines.push('');
187
+ }
188
+ // Generate resource types
189
+ for (const className of Object.keys(resources)) {
190
+ const fields = resources[className];
191
+ if (fallbacks.includes(className)) {
192
+ lines.push(`export type ${className} = Record<string, any>;`);
193
+ lines.push('');
194
+ continue;
195
+ }
196
+ lines.push(`export type ${className} = {`);
197
+ for (const key of Object.keys(fields)) {
198
+ const info = fields[key];
199
+ const type = info.type || 'any';
200
+ const optional = info.optional ? '?' : '';
201
+ lines.push(` ${key}${optional}: ${type};`);
202
+ }
203
+ lines.push('};');
204
+ lines.push('');
205
+ }
206
+ return lines.join('\n');
207
+ }
208
+ /**
209
+ * Generate runtime JavaScript for resources.
210
+ * Resources are type-only, so this just exports an empty object.
211
+ */
212
+ export function generateResourceRuntime() {
213
+ const lines = [];
214
+ lines.push('// Auto-generated by primcloud Vite plugin');
215
+ lines.push('// Resources are type-only exports');
216
+ lines.push('');
217
+ lines.push('export default {};');
218
+ return lines.join('\n');
219
+ }
220
+ /**
221
+ * Generate resource type files (TypeScript declarations and runtime JavaScript).
222
+ */
223
+ export function generateResources(options) {
224
+ const { resourcesDir, enumsDir, modelsDir, outputDir, packageName } = options;
225
+ const collectedEnums = {};
226
+ const resources = {};
227
+ const fallbacks = [];
228
+ if (!existsSync(resourcesDir)) {
229
+ console.warn(`Resources directory not found: ${resourcesDir}`);
230
+ return;
231
+ }
232
+ const files = getPhpFiles(resourcesDir);
233
+ for (const file of files) {
234
+ try {
235
+ const filePath = join(resourcesDir, file);
236
+ const content = readFileSafe(filePath) || '';
237
+ const className = parse(file).name;
238
+ const docShape = extractDocblockArrayShape(content);
239
+ const arrayBlock = extractReturnArrayBlock(content);
240
+ if (!arrayBlock) {
241
+ fallbacks.push(className);
242
+ resources[className] = {};
243
+ }
244
+ else {
245
+ const fields = parseFieldsFromArrayBlock(arrayBlock, className, docShape ? mapDocTypeToTsForShape(docShape) : null, resourcesDir, modelsDir, enumsDir, collectedEnums);
246
+ resources[className] = fields;
247
+ }
248
+ }
249
+ catch (e) {
250
+ console.warn(`Failed to parse resource file: ${file}`, e);
251
+ }
252
+ }
253
+ // Track which enums are actually referenced
254
+ const referencedEnums = new Set(Object.keys(collectedEnums));
255
+ // Generate TypeScript declarations
256
+ const dtsContent = generateResourceTypeScript(resources, fallbacks, referencedEnums);
257
+ const dtsPath = join(outputDir, 'index.d.ts');
258
+ writeFileEnsureDir(dtsPath, dtsContent);
259
+ // Generate runtime JavaScript
260
+ const jsContent = generateResourceRuntime();
261
+ const jsPath = join(outputDir, 'index.js');
262
+ writeFileEnsureDir(jsPath, jsContent);
263
+ // Generate package.json
264
+ const pkgJson = JSON.stringify({
265
+ name: packageName,
266
+ version: '0.0.0',
267
+ main: 'index.js',
268
+ types: 'index.d.ts',
269
+ }, null, 2);
270
+ const pkgPath = join(outputDir, 'package.json');
271
+ writeFileEnsureDir(pkgPath, pkgJson);
272
+ }
273
+ /**
274
+ * Map docblock types to TypeScript for each field in a shape.
275
+ */
276
+ function mapDocTypeToTsForShape(docShape) {
277
+ const result = {};
278
+ for (const [key, type] of Object.entries(docShape)) {
279
+ result[key] = mapDocTypeToTs(type);
280
+ }
281
+ return result;
282
+ }
@@ -0,0 +1,14 @@
1
+ import type { Plugin } from 'vite';
2
+ export type ResourceTypesPluginOptions = {
3
+ cwd?: string;
4
+ };
5
+ /**
6
+ * Vite plugin for generating TypeScript types from Laravel PHP files.
7
+ *
8
+ * This plugin generates separate packages for each type:
9
+ * - @app/enums - PHP enums with labels
10
+ * - @app/resources - Laravel JsonResource types
11
+ * - @app/schemas - (future) Zod schemas from FormRequests
12
+ */
13
+ export default function ferry(options?: ResourceTypesPluginOptions): Plugin;
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAOnC,MAAM,MAAM,0BAA0B,GAAG;IACvC,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,CAAC,OAAO,UAAU,KAAK,CAC3B,OAAO,GAAE,0BAER,GACA,MAAM,CAwFR"}
package/dist/index.js ADDED
@@ -0,0 +1,97 @@
1
+ import { join } from 'node:path';
2
+ import { generateEnums } from './generators/enums.js';
3
+ import { generateResources } from './generators/resources.js';
4
+ import { displayBanner } from './utils/banner.js';
5
+ import { setupEnumWatcher } from './watchers/enums.js';
6
+ import { setupResourceWatcher } from './watchers/resources.js';
7
+ /**
8
+ * Vite plugin for generating TypeScript types from Laravel PHP files.
9
+ *
10
+ * This plugin generates separate packages for each type:
11
+ * - @app/enums - PHP enums with labels
12
+ * - @app/resources - Laravel JsonResource types
13
+ * - @app/schemas - (future) Zod schemas from FormRequests
14
+ */
15
+ export default function ferry(options = {
16
+ cwd: process.cwd(),
17
+ }) {
18
+ const namespace = '@ferry';
19
+ const name = 'vite-plugin-ferry';
20
+ // Directory paths
21
+ const enumsDir = join(options.cwd, 'app/Enums');
22
+ const resourcesDir = join(options.cwd, 'app/Http/Resources');
23
+ const modelsDir = join(options.cwd, 'app/Models');
24
+ // Output directories for each package
25
+ const enumsOutputDir = join(options.cwd, 'node_modules', ...namespace.split('/'), 'enums');
26
+ const resourcesOutputDir = join(options.cwd, 'node_modules', ...namespace.split('/'), 'resources');
27
+ /**
28
+ * Generate all packages.
29
+ */
30
+ function generateAll() {
31
+ // Generate @app/enums package
32
+ generateEnums({
33
+ enumsDir,
34
+ outputDir: enumsOutputDir,
35
+ packageName: `${namespace}/enums`,
36
+ });
37
+ // Generate @app/resources package
38
+ generateResources({
39
+ resourcesDir,
40
+ enumsDir,
41
+ modelsDir,
42
+ outputDir: resourcesOutputDir,
43
+ packageName: `${namespace}/resources`,
44
+ });
45
+ }
46
+ return {
47
+ name,
48
+ enforce: 'pre',
49
+ // Run generation during config resolution so files exist before other plugins need them
50
+ config() {
51
+ try {
52
+ generateAll();
53
+ }
54
+ catch (e) {
55
+ console.error(`[${name}] Error generating types during config():`, e);
56
+ }
57
+ return null;
58
+ },
59
+ // Run generation when build starts
60
+ buildStart() {
61
+ try {
62
+ generateAll();
63
+ }
64
+ catch (e) {
65
+ console.error(`[${name}] Error generating types during buildStart():`, e);
66
+ }
67
+ },
68
+ // Set up watchers for dev server
69
+ configureServer(server) {
70
+ // Display startup banner
71
+ server.httpServer?.once('listening', () => {
72
+ setTimeout(() => {
73
+ displayBanner({
74
+ packages: [`${namespace}/enums`, `${namespace}/resources`],
75
+ version: '1.0.0',
76
+ });
77
+ }, 200);
78
+ });
79
+ // Set up enum watcher
80
+ setupEnumWatcher({
81
+ enumsDir,
82
+ outputDir: enumsOutputDir,
83
+ packageName: `${namespace}/enums`,
84
+ server,
85
+ });
86
+ // Set up resource watcher
87
+ setupResourceWatcher({
88
+ resourcesDir,
89
+ enumsDir,
90
+ modelsDir,
91
+ outputDir: resourcesOutputDir,
92
+ packageName: `${namespace}/resources`,
93
+ server,
94
+ });
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Display a startup banner for the plugin.
3
+ */
4
+ export declare function displayBanner(options: {
5
+ packages: string[];
6
+ version?: string;
7
+ }): void;
8
+ /**
9
+ * Log a file change event.
10
+ */
11
+ export declare function logFileChange(packageName: string, fileName: string): void;
12
+ /**
13
+ * Log a regeneration event.
14
+ */
15
+ export declare function logRegeneration(packageName: string): void;
16
+ /**
17
+ * Log an error.
18
+ */
19
+ export declare function logError(packageName: string, message: string, error?: any): void;
20
+ //# sourceMappingURL=banner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"banner.d.ts","sourceRoot":"","sources":["../../src/utils/banner.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAYrF;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAIzE;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAIzD;AAED;;GAEG;AACH,wBAAgB,QAAQ,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,GAAG,GAAG,IAAI,CAMhF"}
@@ -0,0 +1,40 @@
1
+ import pc from 'picocolors';
2
+ /**
3
+ * Display a startup banner for the plugin.
4
+ */
5
+ export function displayBanner(options) {
6
+ const { packages, version = '1.0.0' } = options;
7
+ console.log('');
8
+ console.log(pc.cyan(' PRIMCLOUD') + pc.dim(` resource-types ${pc.bold(`v${version}`)}`));
9
+ console.log('');
10
+ for (const pkg of packages) {
11
+ console.log(pc.green(' ➜') + ' ' + pc.bold(pkg));
12
+ }
13
+ console.log('');
14
+ }
15
+ /**
16
+ * Log a file change event.
17
+ */
18
+ export function logFileChange(packageName, fileName) {
19
+ const pkgLabel = pc.cyan(`[${packageName}]`);
20
+ const fileLabel = pc.dim(fileName);
21
+ console.log(`${pkgLabel} File changed: ${fileLabel}`);
22
+ }
23
+ /**
24
+ * Log a regeneration event.
25
+ */
26
+ export function logRegeneration(packageName) {
27
+ const pkgLabel = pc.cyan(`[${packageName}]`);
28
+ const message = pc.green('✓') + ' Regenerated types';
29
+ console.log(`${pkgLabel} ${message}`);
30
+ }
31
+ /**
32
+ * Log an error.
33
+ */
34
+ export function logError(packageName, message, error) {
35
+ const pkgLabel = pc.red(`[${packageName}]`);
36
+ console.error(`${pkgLabel} ${pc.red('✗')} ${message}`);
37
+ if (error) {
38
+ console.error(pc.dim(error.stack || error.message || String(error)));
39
+ }
40
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Safely read a file, returning null if it doesn't exist or can't be read.
3
+ */
4
+ export declare function readFileSafe(filePath: string): string | null;
5
+ /**
6
+ * Write a file, ensuring the directory exists.
7
+ */
8
+ export declare function writeFileEnsureDir(filePath: string, content: string): void;
9
+ /**
10
+ * Get all PHP files from a directory.
11
+ */
12
+ export declare function getPhpFiles(dir: string): string[];
13
+ //# sourceMappingURL=file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../../src/utils/file.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM5D;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAI1E;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAKjD"}
@@ -0,0 +1,30 @@
1
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ /**
4
+ * Safely read a file, returning null if it doesn't exist or can't be read.
5
+ */
6
+ export function readFileSafe(filePath) {
7
+ try {
8
+ return readFileSync(filePath, 'utf8');
9
+ }
10
+ catch (e) {
11
+ return null;
12
+ }
13
+ }
14
+ /**
15
+ * Write a file, ensuring the directory exists.
16
+ */
17
+ export function writeFileEnsureDir(filePath, content) {
18
+ const dir = dirname(filePath);
19
+ mkdirSync(dir, { recursive: true });
20
+ writeFileSync(filePath, content, 'utf8');
21
+ }
22
+ /**
23
+ * Get all PHP files from a directory.
24
+ */
25
+ export function getPhpFiles(dir) {
26
+ if (!existsSync(dir)) {
27
+ return [];
28
+ }
29
+ return readdirSync(dir).filter((f) => f.endsWith('.php'));
30
+ }
@@ -0,0 +1,31 @@
1
+ export type EnumCase = {
2
+ key: string;
3
+ value: string;
4
+ label?: string;
5
+ };
6
+ export type EnumDefinition = {
7
+ name: string;
8
+ backing: string | null;
9
+ cases: EnumCase[];
10
+ };
11
+ /**
12
+ * Parse a PHP enum file and extract its definition.
13
+ */
14
+ export declare function parseEnumFile(enumPath: string): EnumDefinition | null;
15
+ /**
16
+ * Parse PHP array pairs from a string like "'key' => 'value'".
17
+ */
18
+ export declare function parsePhpArrayPairs(inside: string): Record<string, string>;
19
+ /**
20
+ * Extract model casts from a PHP model file.
21
+ */
22
+ export declare function getModelCasts(modelPath: string): Record<string, string>;
23
+ /**
24
+ * Extract docblock array shape from PHP file content.
25
+ */
26
+ export declare function extractDocblockArrayShape(phpContent: string): Record<string, string> | null;
27
+ /**
28
+ * Extract the return array block from a toArray() method in a PHP resource.
29
+ */
30
+ export declare function extractReturnArrayBlock(phpContent: string): string | null;
31
+ //# sourceMappingURL=php-parser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"php-parser.d.ts","sourceRoot":"","sources":["../../src/utils/php-parser.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,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;AAEF;;GAEG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CA8CrE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAoBzE;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA4BvE;AAED;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAqE3F;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CASzE"}
@@ -0,0 +1,180 @@
1
+ import { readFileSafe } from './file.js';
2
+ /**
3
+ * Parse a PHP enum file and extract its definition.
4
+ */
5
+ export function parseEnumFile(enumPath) {
6
+ const content = readFileSafe(enumPath);
7
+ if (!content)
8
+ return null;
9
+ // Extract enum name and backing type
10
+ const enumMatch = content.match(/enum\s+([A-Za-z0-9_]+)\s*(?:\:\s*([A-Za-z0-9_]+))?/);
11
+ if (!enumMatch)
12
+ return null;
13
+ const name = enumMatch[1];
14
+ const backing = enumMatch[2] ? enumMatch[2].toLowerCase() : null;
15
+ // Extract enum cases
16
+ const cases = [];
17
+ const explicitCases = [...content.matchAll(/case\s+([A-Za-z0-9_]+)\s*=\s*'([^']*)'\s*;/g)];
18
+ if (explicitCases.length) {
19
+ for (const match of explicitCases) {
20
+ cases.push({ key: match[1], value: match[2] });
21
+ }
22
+ }
23
+ else {
24
+ const implicitCases = [...content.matchAll(/case\s+([A-Za-z0-9_]+)\s*;/g)];
25
+ for (const match of implicitCases) {
26
+ cases.push({ key: match[1], value: match[1] });
27
+ }
28
+ }
29
+ // Parse getLabel() method if it exists
30
+ const labelMethodMatch = content.match(/function\s+getLabel\s*\(\s*\)\s*:\s*string\s*\{[\s\S]*?return\s+match\s*\(\s*\$this\s*\)\s*\{([\s\S]*?)\};/);
31
+ if (labelMethodMatch) {
32
+ const matchBody = labelMethodMatch[1];
33
+ const labelMatches = [...matchBody.matchAll(/self::([A-Za-z0-9_]+)\s*=>\s*'([^']*)'/g)];
34
+ for (const labelMatch of labelMatches) {
35
+ const caseKey = labelMatch[1];
36
+ const labelValue = labelMatch[2];
37
+ const enumCase = cases.find((c) => c.key === caseKey);
38
+ if (enumCase) {
39
+ enumCase.label = labelValue;
40
+ }
41
+ }
42
+ }
43
+ return { name, backing, cases };
44
+ }
45
+ /**
46
+ * Parse PHP array pairs from a string like "'key' => 'value'".
47
+ */
48
+ export function parsePhpArrayPairs(inside) {
49
+ const pairs = {};
50
+ const re = /["'](?<key>[A-Za-z0-9_]+)["']\s*=>\s*(?<val>[^,\n]+)/g;
51
+ for (const m of inside.matchAll(re)) {
52
+ let val = m.groups.val.trim();
53
+ val = val.replace(/[,\s]*$/g, '');
54
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
55
+ val = val.slice(1, -1);
56
+ }
57
+ if (val.endsWith('::class')) {
58
+ val = val.slice(0, -7);
59
+ }
60
+ pairs[m.groups.key] = val;
61
+ }
62
+ return pairs;
63
+ }
64
+ /**
65
+ * Extract model casts from a PHP model file.
66
+ */
67
+ export function getModelCasts(modelPath) {
68
+ const content = readFileSafe(modelPath);
69
+ if (!content)
70
+ return {};
71
+ // Try protected $casts property
72
+ const castsMatch = content.match(/protected\s+\$casts\s*=\s*\[([^\]]*)\]/s);
73
+ if (castsMatch) {
74
+ return parsePhpArrayPairs(castsMatch[1]);
75
+ }
76
+ // Try casts() method
77
+ const castsMethodMatch = content.match(/function\s+casts\s*\([^)]*\)\s*\{[^}]*return\s*\[([^\]]*)\]/s);
78
+ if (castsMethodMatch) {
79
+ return parsePhpArrayPairs(castsMethodMatch[1]);
80
+ }
81
+ // Try class-based casts
82
+ const matches = [...content.matchAll(/["'](?<key>[A-Za-z0-9_]+)["']\s*=>\s*(?<class>[A-Za-z0-9_\\]+)::class/g)];
83
+ const res = {};
84
+ for (const m of matches) {
85
+ const k = m.groups.key;
86
+ let v = m.groups.class;
87
+ v = v.replace(/^\\+/, '');
88
+ res[k] = v;
89
+ }
90
+ return res;
91
+ }
92
+ /**
93
+ * Extract docblock array shape from PHP file content.
94
+ */
95
+ export function extractDocblockArrayShape(phpContent) {
96
+ const match = phpContent.match(/@return\s+array\s*\{/s);
97
+ if (!match)
98
+ return null;
99
+ const startPos = match.index;
100
+ const openBracePos = phpContent.indexOf('{', startPos);
101
+ if (openBracePos === -1)
102
+ return null;
103
+ // Find matching closing brace
104
+ let depth = 0;
105
+ let pos = openBracePos;
106
+ let endPos = null;
107
+ while (pos < phpContent.length) {
108
+ const ch = phpContent[pos];
109
+ if (ch === '{')
110
+ depth++;
111
+ else if (ch === '}') {
112
+ depth--;
113
+ if (depth === 0) {
114
+ endPos = pos;
115
+ break;
116
+ }
117
+ }
118
+ pos++;
119
+ }
120
+ if (endPos === null)
121
+ return null;
122
+ const inside = phpContent.slice(openBracePos + 1, endPos);
123
+ const pairs = {};
124
+ let i = 0;
125
+ while (i < inside.length) {
126
+ // Skip whitespace and commas
127
+ while (i < inside.length && (inside[i].match(/\s/) || inside[i] === ','))
128
+ i++;
129
+ if (i >= inside.length)
130
+ break;
131
+ // Extract key
132
+ const keyMatch = inside.slice(i).match(/^[A-Za-z0-9_]+/);
133
+ if (!keyMatch)
134
+ break;
135
+ const key = keyMatch[0];
136
+ i += key.length;
137
+ // Skip to colon
138
+ while (i < inside.length && /\s/.test(inside[i]))
139
+ i++;
140
+ if (i >= inside.length || inside[i] !== ':')
141
+ break;
142
+ i++;
143
+ // Extract type
144
+ while (i < inside.length && /\s/.test(inside[i]))
145
+ i++;
146
+ const typeStart = i;
147
+ let depthCur = 0;
148
+ while (i < inside.length) {
149
+ const ch = inside[i];
150
+ if (ch === '{' || ch === '<' || ch === '(')
151
+ depthCur++;
152
+ else if (ch === '}' || ch === '>' || ch === ')') {
153
+ if (depthCur > 0)
154
+ depthCur--;
155
+ }
156
+ else if (ch === ',' && depthCur === 0)
157
+ break;
158
+ i++;
159
+ }
160
+ const type = inside.slice(typeStart, i).trim();
161
+ if (type)
162
+ pairs[key] = type;
163
+ if (i < inside.length && inside[i] === ',')
164
+ i++;
165
+ }
166
+ return pairs;
167
+ }
168
+ /**
169
+ * Extract the return array block from a toArray() method in a PHP resource.
170
+ */
171
+ export function extractReturnArrayBlock(phpContent) {
172
+ const match = phpContent.match(/function\s+toArray\s*\([^)]*\)\s*:\s*array\s*\{([\s\S]*?)\n\s*\}/);
173
+ if (!match)
174
+ return null;
175
+ const body = match[1];
176
+ const returnMatch = body.match(/return\s*\[\s*([\s\S]*?)\s*\];/);
177
+ if (!returnMatch)
178
+ return null;
179
+ return returnMatch[1];
180
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Map PHP types to TypeScript types.
3
+ */
4
+ export declare function mapPhpTypeToTs(phpType: string): string;
5
+ /**
6
+ * Map docblock types to TypeScript types.
7
+ */
8
+ export declare function mapDocTypeToTs(docType: string): string;
9
+ /**
10
+ * Parse TypeScript object string to key-value pairs.
11
+ */
12
+ export declare function parseTsObjectStringToPairs(tsObj: string): Record<string, string>;
13
+ //# sourceMappingURL=type-mapper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"type-mapper.d.ts","sourceRoot":"","sources":["../../src/utils/type-mapper.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAWtD;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAiGtD;AAED;;GAEG;AACH,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA8ChF"}
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Map PHP types to TypeScript types.
3
+ */
4
+ export function mapPhpTypeToTs(phpType) {
5
+ const lower = phpType.toLowerCase();
6
+ if (['int', 'integer'].includes(lower))
7
+ return 'number';
8
+ if (['real', 'float', 'double', 'decimal'].includes(lower))
9
+ return 'number';
10
+ if (lower === 'string')
11
+ return 'string';
12
+ if (['bool', 'boolean'].includes(lower))
13
+ return 'boolean';
14
+ if (['array', 'json'].includes(lower))
15
+ return 'any[]';
16
+ if (['datetime', 'date', 'immutable_datetime', 'immutable_date'].includes(lower))
17
+ return 'string';
18
+ return 'any';
19
+ }
20
+ /**
21
+ * Map docblock types to TypeScript types.
22
+ */
23
+ export function mapDocTypeToTs(docType) {
24
+ let type = docType.trim();
25
+ let nullable = false;
26
+ if (type.startsWith('?')) {
27
+ nullable = true;
28
+ type = type.slice(1);
29
+ }
30
+ // Handle array shapes like "array {key: type, ...}"
31
+ const arrShape = type.match(/^array\s*\{(.+)\}$/s);
32
+ if (arrShape) {
33
+ const inside = arrShape[1];
34
+ const parts = [];
35
+ const innerRe = /(?<key>[A-Za-z0-9_]+)\s*:\s*(?<type>[^,\n}]+)/g;
36
+ for (const mm of inside.matchAll(innerRe)) {
37
+ const k = mm.groups.key;
38
+ const t = mm.groups.type.trim();
39
+ parts.push(`${k}: ${mapDocTypeToTs(t)}`);
40
+ }
41
+ const obj = `{ ${parts.join('; ')} }`;
42
+ return nullable ? `${obj} | null` : obj;
43
+ }
44
+ // Handle union types
45
+ const parts = type
46
+ .split('|')
47
+ .map((p) => p.trim())
48
+ .filter(Boolean);
49
+ const mapped = [];
50
+ for (const p of parts) {
51
+ const low = p.toLowerCase();
52
+ if (low === 'null') {
53
+ mapped.push('null');
54
+ continue;
55
+ }
56
+ if (low === 'mixed') {
57
+ mapped.push('any');
58
+ continue;
59
+ }
60
+ if (low === 'array') {
61
+ mapped.push('any[]');
62
+ continue;
63
+ }
64
+ if (['int', 'integer', 'float', 'double', 'number', 'decimal'].includes(low)) {
65
+ mapped.push('number');
66
+ continue;
67
+ }
68
+ if (['bool', 'boolean'].includes(low)) {
69
+ mapped.push('boolean');
70
+ continue;
71
+ }
72
+ if (low.startsWith('string')) {
73
+ mapped.push('string');
74
+ continue;
75
+ }
76
+ if (low === 'object' || low === 'stdclass') {
77
+ mapped.push('Record<string, any>');
78
+ continue;
79
+ }
80
+ // Handle array notation like "Foo[]"
81
+ const arrMatch = p.match(/^(?<inner>[A-Za-z0-9_\\]+)\[\]$/);
82
+ if (arrMatch) {
83
+ const inner = arrMatch.groups.inner.replace(/\\\\/g, '');
84
+ mapped.push(`${inner}[]`);
85
+ continue;
86
+ }
87
+ // Handle generic array like "array<Foo>"
88
+ const genMatch = p.match(/array\s*<\s*([^,>\s]+)\s*>/i);
89
+ if (genMatch) {
90
+ const inner = genMatch[1].replace(/[^A-Za-z0-9_]/g, '');
91
+ mapped.push(`${inner}[]`);
92
+ continue;
93
+ }
94
+ // Handle Record types
95
+ if (/record\s*<\s*[^>]+>/i.test(p) || p.includes('Record')) {
96
+ mapped.push(p.replace('mixed', 'any'));
97
+ continue;
98
+ }
99
+ // Default: sanitize and use as-is
100
+ const san = p.replace(/[^A-Za-z0-9_\\[\]]/g, '').replace(/\\/g, '');
101
+ mapped.push(san === '' ? 'any' : san);
102
+ }
103
+ if (nullable && !mapped.includes('null')) {
104
+ mapped.push('null');
105
+ }
106
+ return Array.from(new Set(mapped)).join(' | ');
107
+ }
108
+ /**
109
+ * Parse TypeScript object string to key-value pairs.
110
+ */
111
+ export function parseTsObjectStringToPairs(tsObj) {
112
+ const pairs = {};
113
+ let inside = tsObj.trim();
114
+ if (!inside.startsWith('{') || !inside.endsWith('}'))
115
+ return pairs;
116
+ inside = inside.slice(1, -1);
117
+ let i = 0;
118
+ while (i < inside.length) {
119
+ // Skip whitespace and separators
120
+ while (i < inside.length && (/\s/.test(inside[i]) || inside[i] === ';' || inside[i] === ','))
121
+ i++;
122
+ // Extract key
123
+ const keyMatch = inside.slice(i).match(/^[A-Za-z0-9_]+\??/);
124
+ if (!keyMatch)
125
+ break;
126
+ const keyRaw = keyMatch[0];
127
+ i += keyRaw.length;
128
+ const key = keyRaw.endsWith('?') ? keyRaw.slice(0, -1) : keyRaw;
129
+ // Skip to colon
130
+ while (i < inside.length && /\s/.test(inside[i]))
131
+ i++;
132
+ if (i >= inside.length || inside[i] !== ':')
133
+ break;
134
+ i++;
135
+ // Extract type
136
+ while (i < inside.length && /\s/.test(inside[i]))
137
+ i++;
138
+ const typeStart = i;
139
+ let depth = 0;
140
+ while (i < inside.length) {
141
+ const ch = inside[i];
142
+ if (ch === '{' || ch === '(' || ch === '<')
143
+ depth++;
144
+ else if (ch === '}' || ch === ')' || ch === '>') {
145
+ if (depth > 0)
146
+ depth--;
147
+ }
148
+ else if ((ch === ';' || ch === ',') && depth === 0)
149
+ break;
150
+ i++;
151
+ }
152
+ const type = inside.slice(typeStart, i).trim();
153
+ pairs[key] = type === '' ? 'any' : type;
154
+ if (i < inside.length && (inside[i] === ';' || inside[i] === ','))
155
+ i++;
156
+ }
157
+ return pairs;
158
+ }
@@ -0,0 +1,10 @@
1
+ import type { ViteDevServer } from 'vite';
2
+ import { type EnumGeneratorOptions } from '../generators/enums.js';
3
+ export type EnumWatcherOptions = EnumGeneratorOptions & {
4
+ server: ViteDevServer;
5
+ };
6
+ /**
7
+ * Set up a watcher for enum files.
8
+ */
9
+ export declare function setupEnumWatcher(options: EnumWatcherOptions): void;
10
+ //# sourceMappingURL=enums.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"enums.d.ts","sourceRoot":"","sources":["../../src/watchers/enums.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,MAAM,CAAC;AAC1C,OAAO,EAAiB,KAAK,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAGlF,MAAM,MAAM,kBAAkB,GAAG,oBAAoB,GAAG;IACtD,MAAM,EAAE,aAAa,CAAC;CACvB,CAAC;AAEF;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CA6BlE"}
@@ -0,0 +1,30 @@
1
+ import { join, basename } from 'node:path';
2
+ import { generateEnums } from '../generators/enums.js';
3
+ import { logError, logFileChange, logRegeneration } from '../utils/banner.js';
4
+ /**
5
+ * Set up a watcher for enum files.
6
+ */
7
+ export function setupEnumWatcher(options) {
8
+ const { enumsDir, outputDir, packageName, server } = options;
9
+ const enumPattern = join(enumsDir, '*.php');
10
+ const generatedJsPath = join(outputDir, 'index.js');
11
+ // Watch PHP enum files
12
+ server.watcher.add(enumPattern);
13
+ // Also watch the generated JS file (for HMR)
14
+ server.watcher.add(generatedJsPath);
15
+ server.watcher.on('change', (filePath) => {
16
+ if (filePath.startsWith(enumsDir)) {
17
+ try {
18
+ logFileChange('enums', basename(filePath));
19
+ // Regenerate enum files
20
+ generateEnums({ enumsDir, outputDir, packageName });
21
+ // Tell Vite the generated file changed (triggers normal HMR)
22
+ server.watcher.emit('change', generatedJsPath);
23
+ logRegeneration('enums');
24
+ }
25
+ catch (e) {
26
+ logError('enums', 'Error regenerating enum types', e);
27
+ }
28
+ }
29
+ });
30
+ }
@@ -0,0 +1,10 @@
1
+ import type { ViteDevServer } from 'vite';
2
+ import { type ResourceGeneratorOptions } from '../generators/resources.js';
3
+ export type ResourceWatcherOptions = ResourceGeneratorOptions & {
4
+ server: ViteDevServer;
5
+ };
6
+ /**
7
+ * Set up a watcher for resource and model files.
8
+ */
9
+ export declare function setupResourceWatcher(options: ResourceWatcherOptions): void;
10
+ //# sourceMappingURL=resources.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resources.d.ts","sourceRoot":"","sources":["../../src/watchers/resources.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,MAAM,CAAC;AAC1C,OAAO,EAAqB,KAAK,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AAG9F,MAAM,MAAM,sBAAsB,GAAG,wBAAwB,GAAG;IAC9D,MAAM,EAAE,aAAa,CAAC;CACvB,CAAC;AAEF;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAqC1E"}
@@ -0,0 +1,36 @@
1
+ import { join, basename } from 'node:path';
2
+ import { generateResources } from '../generators/resources.js';
3
+ import { logError, logFileChange, logRegeneration } from '../utils/banner.js';
4
+ /**
5
+ * Set up a watcher for resource and model files.
6
+ */
7
+ export function setupResourceWatcher(options) {
8
+ const { resourcesDir, enumsDir, modelsDir, outputDir, packageName, server } = options;
9
+ const resourcePattern = join(resourcesDir, '*.php');
10
+ const modelPattern = join(modelsDir, '*.php');
11
+ const generatedDtsPath = join(outputDir, 'index.d.ts');
12
+ // Watch PHP resource and model files
13
+ server.watcher.add(resourcePattern);
14
+ server.watcher.add(modelPattern);
15
+ // Also watch the generated .d.ts file
16
+ server.watcher.add(generatedDtsPath);
17
+ const handleChange = (filePath) => {
18
+ if (filePath.startsWith(resourcesDir) || filePath.startsWith(modelsDir)) {
19
+ try {
20
+ const isModel = filePath.startsWith(modelsDir);
21
+ const fileType = isModel ? 'model' : 'resource';
22
+ logFileChange(fileType, basename(filePath));
23
+ // Regenerate resource types
24
+ generateResources({ resourcesDir, enumsDir, modelsDir, outputDir, packageName });
25
+ // Tell Vite the generated type file changed
26
+ // TypeScript will pick up changes automatically
27
+ server.watcher.emit('change', generatedDtsPath);
28
+ logRegeneration('resources');
29
+ }
30
+ catch (e) {
31
+ logError('resources', 'Error regenerating resource types', e);
32
+ }
33
+ }
34
+ };
35
+ server.watcher.on('change', handleChange);
36
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "vite-plugin-ferry",
3
+ "prettier": "@aniftyco/prettier",
4
+ "description": "Ferries Laravel types to your TypeScript frontend",
5
+ "type": "module",
6
+ "version": "0.1.0",
7
+ "repository": "https://github.com/aniftyco/vite-plugin-ferry",
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "start": "tsc --watch",
15
+ "build": "rm -rf dist/ && tsc",
16
+ "prepublishOnly": "rm -rf dist/ && tsc"
17
+ },
18
+ "devDependencies": {
19
+ "@aniftyco/prettier": "^1.3.0",
20
+ "@types/node": "^24",
21
+ "prettier": "^3.6.2",
22
+ "typescript": "^5.9.2",
23
+ "vite": "^7.3.0"
24
+ }
25
+ }