From acbc46c25454f90f6554c3cb3ed00f2a3956f0e9 Mon Sep 17 00:00:00 2001 From: Josh Manders Date: Mon, 5 Jan 2026 20:18:39 -0800 Subject: [PATCH 01/11] refactor(php-parser): use php-parser AST instead of regex - Add php-parser package for proper PHP AST parsing - Use parseEval() which doesn't require PhpParserTypes.Engine; + +// Initialize the PHP parser (PHP 8+ only) +const parser = new PhpParser({ + parser: { + extractDoc: true, + php8: true, + }, + ast: { + withPositions: false, + }, +}); export type EnumCase = { key: string; - value: string; + value: string | number; label?: string; }; @@ -13,49 +28,181 @@ export type EnumDefinition = { }; /** - * Parse a PHP enum file and extract its definition. + * Parse PHP content and return the AST. + * Uses parseEval which doesn't require boolean): boolean { + const obj = node as any; + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (val && typeof val === 'object' && val.kind) { + if (callback(val)) return true; + } else if (Array.isArray(val)) { + for (const item of val) { + if (item && typeof item === 'object' && item.kind) { + if (callback(item)) return true; + } + } + } + } + return false; +} - const name = enumMatch[1]; - const backing = enumMatch[2] ? enumMatch[2].toLowerCase() : null; +/** + * Find a node by kind in the AST. + */ +function findNodeByKind(ast: PhpParserTypes.Node, kind: string): PhpParserTypes.Node | null { + if (ast.kind === kind) return ast; + + let result: PhpParserTypes.Node | null = null; + walkChildren(ast, (child) => { + const found = findNodeByKind(child, kind); + if (found) { + result = found; + return true; + } + return false; + }); - // Extract enum cases - const cases: EnumCase[] = []; - const explicitCases = [...content.matchAll(/case\s+([A-Za-z0-9_]+)\s*=\s*'([^']*)'\s*;/g)]; + return result; +} + +/** + * Find all nodes of a specific kind in the AST. + */ +function findAllNodesByKind(ast: PhpParserTypes.Node, kind: string): PhpParserTypes.Node[] { + const results: PhpParserTypes.Node[] = []; - if (explicitCases.length) { - for (const match of explicitCases) { - cases.push({ key: match[1], value: match[2] }); + function walk(node: PhpParserTypes.Node) { + if (node.kind === kind) { + results.push(node); } - } else { - const implicitCases = [...content.matchAll(/case\s+([A-Za-z0-9_]+)\s*;/g)]; - for (const match of implicitCases) { - cases.push({ key: match[1], value: match[1] }); + walkChildren(node, (child) => { + walk(child); + return false; + }); + } + + walk(ast); + return results; +} + +/** + * Extract string value from a PHP literal node. + */ +function getStringValue(node: PhpParserTypes.Node): string | null { + if (node.kind === 'string') { + return (node as PhpParserTypes.String).value; + } + if (node.kind === 'number') { + return String((node as PhpParserTypes.Number).value); + } + return null; +} + +/** + * Parse PHP enum content and extract its definition. + * This is a pure function that takes PHP source code as input. + */ +export function parseEnumContent(phpContent: string): EnumDefinition | null { + const ast = parsePhp(phpContent); + if (!ast) return null; + + // Find the enum declaration + const enumNode = findNodeByKind(ast, 'enum') as PhpParserTypes.Enum | null; + if (!enumNode) return null; + + const name = typeof enumNode.name === 'string' ? enumNode.name : (enumNode.name as PhpParserTypes.Identifier).name; + const backing = enumNode.valueType ? (enumNode.valueType as PhpParserTypes.Identifier).name.toLowerCase() : null; + + // Extract enum cases + const cases: EnumCase[] = []; + const enumCases = findAllNodesByKind(enumNode, 'enumcase') as PhpParserTypes.EnumCase[]; + + for (const enumCase of enumCases) { + // Name can be an Identifier or string + const key = + typeof enumCase.name === 'string' + ? enumCase.name + : (enumCase.name as PhpParserTypes.Identifier).name; + + let value: string | number; + if (enumCase.value !== null && enumCase.value !== undefined) { + // Value is a String or Number node (types say string|number but runtime is Node) + const valueNode = enumCase.value as unknown as PhpParserTypes.Node; + if (typeof valueNode === 'object' && valueNode.kind) { + if (valueNode.kind === 'number') { + // php-parser returns number values as strings, convert to actual number + value = Number((valueNode as PhpParserTypes.Number).value); + } else { + const extracted = getStringValue(valueNode); + value = extracted !== null ? extracted : key; + } + } else { + value = String(enumCase.value); + } + } else { + value = key; } + + cases.push({ key, value }); } // Parse getLabel() method if it exists - 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]*?)\};/ - ); - - if (labelMethodMatch) { - const matchBody = labelMethodMatch[1]; - const labelMatches = [...matchBody.matchAll(/self::([A-Za-z0-9_]+)\s*=>\s*'([^']*)'/g)]; - - for (const labelMatch of labelMatches) { - const caseKey = labelMatch[1]; - const labelValue = labelMatch[2]; - const enumCase = cases.find((c) => c.key === caseKey); - if (enumCase) { - enumCase.label = labelValue; + const methods = findAllNodesByKind(enumNode, 'method') as PhpParserTypes.Method[]; + const getLabelMethod = methods.find((m) => { + const methodName = typeof m.name === 'string' ? m.name : (m.name as PhpParserTypes.Identifier).name; + return methodName === 'getLabel'; + }); + + if (getLabelMethod && getLabelMethod.body) { + // Find match expression in the method + const matchNode = findNodeByKind(getLabelMethod.body, 'match') as PhpParserTypes.Match | null; + if (matchNode && matchNode.arms) { + for (const arm of matchNode.arms) { + if (arm.conds) { + for (const cond of arm.conds) { + // Handle self::CASE_NAME + if (cond.kind === 'staticlookup') { + const lookup = cond as PhpParserTypes.StaticLookup; + const offset = lookup.offset; + const caseName = + typeof offset === 'string' + ? offset + : offset.kind === 'identifier' + ? (offset as PhpParserTypes.Identifier).name + : null; + + if (caseName) { + const labelValue = getStringValue(arm.body); + if (labelValue !== null) { + const enumCase = cases.find((c) => c.key === caseName); + if (enumCase) { + enumCase.label = labelValue; + } + } + } + } + } + } } } } @@ -64,65 +211,96 @@ export function parseEnumFile(enumPath: string): EnumDefinition | null { } /** - * Parse PHP array pairs from a string like "'key' => 'value'". + * Extract key-value pairs from a PHP array node. */ -export function parsePhpArrayPairs(inside: string): Record { +function extractArrayPairs(arrayNode: PhpParserTypes.Array): Record { const pairs: Record = {}; - const re = /["'](?[A-Za-z0-9_]+)["']\s*=>\s*(?[^,\n]+)/g; - for (const m of inside.matchAll(re)) { - let val = (m as any).groups.val.trim(); - val = val.replace(/[,\s]*$/g, ''); - - if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { - val = val.slice(1, -1); - } + for (const item of arrayNode.items) { + if (item.kind === 'entry') { + const entry = item as PhpParserTypes.Entry; + const key = entry.key ? getStringValue(entry.key) : null; + if (!key) continue; + + const value = entry.value; + let strValue: string | null = null; + + if (value.kind === 'string' || value.kind === 'number') { + strValue = getStringValue(value); + } else if (value.kind === 'staticlookup') { + // Handle Foo::class + const lookup = value as PhpParserTypes.StaticLookup; + const offset = lookup.offset; + if ( + offset && + offset.kind === 'identifier' && + (offset as PhpParserTypes.Identifier).name === 'class' + ) { + const what = lookup.what; + if (what.kind === 'name') { + strValue = (what as PhpParserTypes.Name).name.replace(/^\\+/, ''); + } + } + } - if (val.endsWith('::class')) { - val = val.slice(0, -7); + if (strValue !== null) { + pairs[key] = strValue; + } } - - pairs[(m as any).groups.key] = val; } return pairs; } /** - * Extract model casts from a PHP model file. + * Parse model casts from PHP model content. + * This is a pure function that takes PHP source code as input. */ -export function getModelCasts(modelPath: string): Record { - const content = readFileSafe(modelPath); - if (!content) return {}; - - // Try protected $casts property - const castsMatch = content.match(/protected\s+\$casts\s*=\s*\[([^\]]*)\]/s); - if (castsMatch) { - return parsePhpArrayPairs(castsMatch[1]); - } - - // Try casts() method - const castsMethodMatch = content.match(/function\s+casts\s*\([^)]*\)\s*\{[^}]*return\s*\[([^\]]*)\]/s); - if (castsMethodMatch) { - return parsePhpArrayPairs(castsMethodMatch[1]); +export function parseModelCasts(phpContent: string): Record { + const ast = parsePhp(phpContent); + if (!ast) return {}; + + // Find the class + const classNode = findNodeByKind(ast, 'class') as PhpParserTypes.Class | null; + if (!classNode) return {}; + + // Look for protected $casts property + const propertyStatements = findAllNodesByKind(classNode, 'propertystatement') as PhpParserTypes.PropertyStatement[]; + + for (const propStmt of propertyStatements) { + for (const prop of propStmt.properties) { + // prop.name can be a string or Identifier + const propName = + typeof prop.name === 'string' + ? prop.name + : (prop.name as unknown as PhpParserTypes.Identifier).name; + if (propName === 'casts' && prop.value && prop.value.kind === 'array') { + return extractArrayPairs(prop.value as PhpParserTypes.Array); + } + } } - // Try class-based casts - const matches = [...content.matchAll(/["'](?[A-Za-z0-9_]+)["']\s*=>\s*(?[A-Za-z0-9_\\]+)::class/g)]; - const res: Record = {}; - - for (const m of matches) { - const k = (m as any).groups.key; - let v = (m as any).groups.class; - v = v.replace(/^\\+/, ''); - res[k] = v; + // Look for casts() method + const methods = findAllNodesByKind(classNode, 'method') as PhpParserTypes.Method[]; + const castsMethod = methods.find((m) => { + const methodName = typeof m.name === 'string' ? m.name : (m.name as PhpParserTypes.Identifier).name; + return methodName === 'casts'; + }); + + if (castsMethod && castsMethod.body) { + // Find return statement with array + const returnNode = findNodeByKind(castsMethod.body, 'return') as PhpParserTypes.Return | null; + if (returnNode && returnNode.expr && returnNode.expr.kind === 'array') { + return extractArrayPairs(returnNode.expr as PhpParserTypes.Array); + } } - return res; + return {}; } /** - * Extract docblock array shape from PHP file content. + * Extract docblock array shape from PHP content. + * This is a pure function that takes PHP source code as input. */ export function extractDocblockArrayShape(phpContent: string): Record | null { const match = phpContent.match(/@return\s+array\s*\{/s); @@ -152,7 +330,10 @@ export function extractDocblockArrayShape(phpContent: string): Record = {}; let i = 0; @@ -196,7 +377,8 @@ export function extractDocblockArrayShape(phpContent: string): Record Date: Mon, 5 Jan 2026 20:18:50 -0800 Subject: [PATCH 02/11] feat(ts-generator): add TypeScript API code generation utilities - Add printNode/printNodes for AST to string conversion - Add createEnum for enum declarations - Add createObjectLiteral/createConstObject for object expressions - Add createTypeAlias/createTypeLiteral for type declarations - Add createImportType for type-only imports - Add parseTypeString for parsing type strings to AST nodes - Support configurable multiLine formatting for pretty printing --- src/utils/ts-generator.ts | 253 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 src/utils/ts-generator.ts diff --git a/src/utils/ts-generator.ts b/src/utils/ts-generator.ts new file mode 100644 index 0000000..8978f18 --- /dev/null +++ b/src/utils/ts-generator.ts @@ -0,0 +1,253 @@ +import ts from 'typescript'; + +const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + +/** + * Print a TypeScript node to a string. + */ +export function printNode(node: ts.Node): string { + const sourceFile = ts.createSourceFile('output.ts', '', ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); + return printer.printNode(ts.EmitHint.Unspecified, node, sourceFile); +} + +/** + * Print multiple TypeScript nodes to a string with blank lines between them. + */ +export function printNodes(nodes: ts.Node[]): string { + return nodes.map(printNode).join('\n\n'); +} + +/** + * Create a string literal type node. + */ +export function createStringLiteral(value: string): ts.StringLiteral { + return ts.factory.createStringLiteral(value); +} + +/** + * Create a numeric literal node. + */ +export function createNumericLiteral(value: number): ts.NumericLiteral { + return ts.factory.createNumericLiteral(value); +} + +/** + * Create an enum declaration. + */ +export function createEnum( + name: string, + members: Array<{ key: string; value: string | number }> +): ts.EnumDeclaration { + const enumMembers = members.map((m) => { + const initializer = typeof m.value === 'number' ? createNumericLiteral(m.value) : createStringLiteral(m.value); + return ts.factory.createEnumMember(ts.factory.createIdentifier(m.key), initializer); + }); + + return ts.factory.createEnumDeclaration( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + ts.factory.createIdentifier(name), + enumMembers + ); +} + +/** + * Create an object literal expression. + */ +export function createObjectLiteral( + properties: Array<{ key: string; value: ts.Expression }>, + multiLine = true +): ts.ObjectLiteralExpression { + const objectProperties = properties.map((p) => + ts.factory.createPropertyAssignment(ts.factory.createIdentifier(p.key), p.value) + ); + return ts.factory.createObjectLiteralExpression(objectProperties, multiLine); +} + +/** + * Create a const declaration with an object literal. + */ +export function createConstObject( + name: string, + properties: Array<{ key: string; value: ts.Expression }>, + multiLine = true +): ts.VariableStatement { + const objectLiteral = createObjectLiteral(properties, multiLine); + + return ts.factory.createVariableStatement( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + ts.factory.createVariableDeclarationList( + [ts.factory.createVariableDeclaration(ts.factory.createIdentifier(name), undefined, undefined, objectLiteral)], + ts.NodeFlags.Const + ) + ); +} + +/** + * Create a declare const statement with a typed object. + */ +export function createDeclareConstWithType(name: string, type: ts.TypeNode): ts.VariableStatement { + return ts.factory.createVariableStatement( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword), ts.factory.createModifier(ts.SyntaxKind.DeclareKeyword)], + ts.factory.createVariableDeclarationList( + [ts.factory.createVariableDeclaration(ts.factory.createIdentifier(name), undefined, type, undefined)], + ts.NodeFlags.Const + ) + ); +} + +/** + * Create a type alias declaration. + */ +export function createTypeAlias(name: string, type: ts.TypeNode): ts.TypeAliasDeclaration { + return ts.factory.createTypeAliasDeclaration( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + ts.factory.createIdentifier(name), + undefined, + type + ); +} + +/** + * Create a type literal with property signatures. + */ +export function createTypeLiteral( + properties: Array<{ name: string; type: ts.TypeNode; optional?: boolean }> +): ts.TypeLiteralNode { + const members = properties.map((p) => + ts.factory.createPropertySignature( + undefined, + ts.factory.createIdentifier(p.name), + p.optional ? ts.factory.createToken(ts.SyntaxKind.QuestionToken) : undefined, + p.type + ) + ); + return ts.factory.createTypeLiteralNode(members); +} + +/** + * Create an import type declaration. + */ +export function createImportType(names: string[], from: string): ts.ImportDeclaration { + const importSpecifiers = names.map((name) => + ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier(name)) + ); + + return ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + true, // isTypeOnly + undefined, + ts.factory.createNamedImports(importSpecifiers) + ), + ts.factory.createStringLiteral(from) + ); +} + +/** + * Parse a type string into a TypeNode. + */ +export function parseTypeString(typeStr: string): ts.TypeNode { + // Handle common types + switch (typeStr) { + case 'string': + return ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); + case 'number': + return ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); + case 'boolean': + return ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); + case 'any': + return ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); + case 'null': + return ts.factory.createLiteralTypeNode(ts.factory.createNull()); + case 'undefined': + return ts.factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword); + } + + // Handle array types like "string[]" or "User[]" + if (typeStr.endsWith('[]')) { + const elementType = parseTypeString(typeStr.slice(0, -2)); + return ts.factory.createArrayTypeNode(elementType); + } + + // Handle union types like "string | null" + if (typeStr.includes(' | ')) { + const types = typeStr.split(' | ').map((t) => parseTypeString(t.trim())); + return ts.factory.createUnionTypeNode(types); + } + + // Handle Record + const recordMatch = typeStr.match(/^Record<([^,]+),\s*([^>]+)>$/); + if (recordMatch) { + const keyType = parseTypeString(recordMatch[1].trim()); + const valueType = parseTypeString(recordMatch[2].trim()); + return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier('Record'), [keyType, valueType]); + } + + // Handle inline object types like "{ key: type; ... }" + if (typeStr.startsWith('{') && typeStr.endsWith('}')) { + const inner = typeStr.slice(1, -1).trim(); + if (!inner) { + return ts.factory.createTypeLiteralNode([]); + } + const properties = parseObjectTypeProperties(inner); + return createTypeLiteral(properties); + } + + // Default to type reference (custom types like "UserResource") + return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier(typeStr), undefined); +} + +/** + * Parse object type properties from a string like "key: type; key2: type2" + */ +function parseObjectTypeProperties(inner: string): Array<{ name: string; type: ts.TypeNode; optional?: boolean }> { + const properties: Array<{ name: string; type: ts.TypeNode; optional?: boolean }> = []; + let depth = 0; + let current = ''; + let i = 0; + + while (i < inner.length) { + const ch = inner[i]; + if (ch === '{' || ch === '<' || ch === '(') depth++; + else if (ch === '}' || ch === '>' || ch === ')') depth--; + + if ((ch === ';' || ch === ',') && depth === 0) { + const prop = parsePropertyString(current.trim()); + if (prop) properties.push(prop); + current = ''; + } else { + current += ch; + } + i++; + } + + // Handle last property + const lastProp = parsePropertyString(current.trim()); + if (lastProp) properties.push(lastProp); + + return properties; +} + +/** + * Parse a single property string like "key: type" or "key?: type" + */ +function parsePropertyString(propStr: string): { name: string; type: ts.TypeNode; optional?: boolean } | null { + if (!propStr) return null; + + const match = propStr.match(/^([A-Za-z0-9_]+)(\?)?:\s*(.+)$/); + if (!match) return null; + + const [, name, optional, typeStr] = match; + return { + name, + type: parseTypeString(typeStr.trim()), + optional: !!optional, + }; +} + +/** + * Create an export default statement. + */ +export function createExportDefault(expression: ts.Expression): ts.ExportAssignment { + return ts.factory.createExportAssignment(undefined, false, expression); +} From a381bff68371b47fa075c8c6842b5a287529b6ff Mon Sep 17 00:00:00 2001 From: Josh Manders Date: Mon, 5 Jan 2026 20:18:59 -0800 Subject: [PATCH 03/11] refactor(enums): use TypeScript API for code generation - Replace manual string building with TypeScript factory API - Use createEnum for traditional enums - Use createDeclareConstWithType for enums with labels - Use createObjectLiteral for runtime enum values - Add prettyPrint parameter to generateEnumRuntime --- src/generators/enums.ts | 112 ++++++++++++++++++++++------------------ 1 file changed, 62 insertions(+), 50 deletions(-) diff --git a/src/generators/enums.ts b/src/generators/enums.ts index 186bf77..df16867 100644 --- a/src/generators/enums.ts +++ b/src/generators/enums.ts @@ -1,92 +1,101 @@ +import ts from 'typescript'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { getPhpFiles, writeFileEnsureDir } from '../utils/file.js'; -import { parseEnumFile, type EnumDefinition } from '../utils/php-parser.js'; +import { getPhpFiles, readFileSafe, writeFileEnsureDir } from '../utils/file.js'; +import { parseEnumContent, type EnumDefinition } from '../utils/php-parser.js'; +import { + printNodes, + createEnum, + createConstObject, + createObjectLiteral, + createDeclareConstWithType, + createTypeLiteral, + createStringLiteral, + createNumericLiteral, + createExportDefault, + printNode, +} from '../utils/ts-generator.js'; export type EnumGeneratorOptions = { enumsDir: string; outputDir: string; packageName: string; + prettyPrint?: boolean; }; /** * Generate TypeScript type declarations for enums. */ export function generateEnumTypeScript(enums: Record): string { - const lines: string[] = []; - lines.push('// This file is auto-generated by the primcloud Vite plugin.'); - lines.push('// Do not edit directly.'); - lines.push(''); + const nodes: ts.Node[] = []; for (const enumName of Object.keys(enums)) { const enumDef = enums[enumName]; const hasLabels = enumDef.cases.some((c) => c.label); if (hasLabels) { - // Generate a const object with typed properties for enums with labels - lines.push(`export declare const ${enumDef.name}: {`); - for (const c of enumDef.cases) { - const val = String(c.value).replace(/'/g, "\\'"); - const label = c.label ? String(c.label).replace(/'/g, "\\'") : val; - lines.push(` ${c.key}: { value: '${val}'; label: '${label}' };`); - } - lines.push('};'); - lines.push(''); + // Generate a declare const with typed properties for enums with labels + const properties = enumDef.cases.map((c) => ({ + name: c.key, + type: createTypeLiteral([ + { name: 'value', type: ts.factory.createLiteralTypeNode(createStringLiteral(String(c.value))) }, + { + name: 'label', + type: ts.factory.createLiteralTypeNode(createStringLiteral(c.label || String(c.value))), + }, + ]), + })); + + nodes.push(createDeclareConstWithType(enumDef.name, createTypeLiteral(properties))); } else { - // Generate a traditional enum for enums without labels - lines.push(`export enum ${enumDef.name} {`); - for (const c of enumDef.cases) { - const val = c.value; - if (enumDef.backing === 'int' || enumDef.backing === 'integer') { - if (!isNaN(Number(val))) { - lines.push(` ${c.key} = ${val},`); - } else { - lines.push(` ${c.key} = '${val}',`); - } - } else { - lines.push(` ${c.key} = '${String(val).replace(/'/g, "\\'")}',`); - } - } - lines.push('}'); - lines.push(''); + // Generate a traditional enum + nodes.push(createEnum(enumDef.name, enumDef.cases.map((c) => ({ key: c.key, value: c.value })))); } } - return lines.join('\n'); + return nodes.length > 0 ? printNodes(nodes) + '\n' : ''; } /** * Generate runtime JavaScript for enums. */ -export function generateEnumRuntime(enums: Record): string { - const lines: string[] = []; - lines.push('// Auto-generated by primcloud Vite plugin'); - lines.push(''); +export function generateEnumRuntime(enums: Record, prettyPrint = true): string { + const nodes: ts.Node[] = []; for (const enumName of Object.keys(enums)) { const enumDef = enums[enumName]; const hasLabels = enumDef.cases.some((c) => c.label); - lines.push(`export const ${enumDef.name} = {`); - for (const c of enumDef.cases) { - const val = String(c.value).replace(/'/g, "\\'"); + const properties = enumDef.cases.map((c) => { + let value: ts.Expression; if (hasLabels) { - const label = c.label ? String(c.label).replace(/'/g, "\\'") : val; - lines.push(` ${c.key}: { value: '${val}', label: '${label}' },`); + value = createObjectLiteral( + [ + { key: 'value', value: createStringLiteral(String(c.value)) }, + { key: 'label', value: createStringLiteral(c.label || String(c.value)) }, + ], + prettyPrint + ); + } else if (typeof c.value === 'number') { + value = createNumericLiteral(c.value); } else { - lines.push(` ${c.key}: '${val}',`); + value = createStringLiteral(String(c.value)); } - } - lines.push('};'); - lines.push(''); + return { key: c.key, value }; + }); + + nodes.push(createConstObject(enumDef.name, properties)); } - lines.push('export default {};'); - return lines.join('\n'); + // Add export default {} + nodes.push(createExportDefault(ts.factory.createObjectLiteralExpression([]))); + + return nodes.map(printNode).join('\n\n') + '\n'; } /** * Collect all enum definitions from the enums directory. + * This is a plugin-level function that handles file I/O. */ export function collectEnums(enumsDir: string): Record { const enums: Record = {}; @@ -100,7 +109,10 @@ export function collectEnums(enumsDir: string): Record { for (const file of enumFiles) { try { const enumPath = join(enumsDir, file); - const def = parseEnumFile(enumPath); + const content = readFileSafe(enumPath); + if (!content) continue; + + const def = parseEnumContent(content); if (def) { enums[def.name] = def; } @@ -117,7 +129,7 @@ export function collectEnums(enumsDir: string): Record { * Generate enum files (TypeScript declarations and runtime JavaScript). */ export function generateEnums(options: EnumGeneratorOptions): void { - const { enumsDir, outputDir, packageName } = options; + const { enumsDir, outputDir, packageName, prettyPrint = true } = options; // Collect all enums const enums = collectEnums(enumsDir); @@ -128,7 +140,7 @@ export function generateEnums(options: EnumGeneratorOptions): void { writeFileEnsureDir(dtsPath, dtsContent); // Generate runtime JavaScript - const jsContent = generateEnumRuntime(enums); + const jsContent = generateEnumRuntime(enums, prettyPrint); const jsPath = join(outputDir, 'index.js'); writeFileEnsureDir(jsPath, jsContent); From e09966343b70d9bb1b816d765570039f0cd5d47b Mon Sep 17 00:00:00 2001 From: Josh Manders Date: Mon, 5 Jan 2026 20:19:07 -0800 Subject: [PATCH 04/11] refactor(resources): use TypeScript API for code generation - Replace manual string building with TypeScript factory API - Use createTypeAlias for resource type declarations - Use createTypeLiteral for property signatures - Use createImportType for enum imports - Add prettyPrint option to ResourceGeneratorOptions --- src/generators/resources.ts | 97 +++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/src/generators/resources.ts b/src/generators/resources.ts index 00c2a11..cad4088 100644 --- a/src/generators/resources.ts +++ b/src/generators/resources.ts @@ -1,14 +1,22 @@ +import ts from 'typescript'; import { existsSync } from 'node:fs'; import { join, parse } from 'node:path'; import { getPhpFiles, readFileSafe, writeFileEnsureDir } from '../utils/file.js'; import { extractDocblockArrayShape, extractReturnArrayBlock, - getModelCasts, - parseEnumFile, + parseEnumContent, + parseModelCasts, type EnumDefinition, } from '../utils/php-parser.js'; import { mapDocTypeToTs, mapPhpTypeToTs, parseTsObjectStringToPairs } from '../utils/type-mapper.js'; +import { + printNode, + createTypeAlias, + createImportType, + parseTypeString, + createTypeLiteral, +} from '../utils/ts-generator.js'; export type ResourceGeneratorOptions = { resourcesDir: string; @@ -16,9 +24,10 @@ export type ResourceGeneratorOptions = { modelsDir: string; outputDir: string; packageName: string; + prettyPrint?: boolean; }; -type FieldInfo = { +export type FieldInfo = { type: string; optional: boolean; }; @@ -28,7 +37,6 @@ type FieldInfo = { */ function mapCastToType(cast: string, enumsDir: string, collectedEnums: Record): string { const original = cast; - const lower = cast.toLowerCase(); // Try to find enum in app/Enums const match = original.match(/([A-Za-z0-9_\\]+)$/); @@ -36,10 +44,13 @@ function mapCastToType(cast: string, enumsDir: string, collectedEnums: Record | null, @@ -254,16 +268,12 @@ export function generateResourceTypeScript( fallbacks: string[], referencedEnums: Set ): string { - const lines: string[] = []; - lines.push('// This file is auto-generated by the primcloud Vite plugin.'); - lines.push('// Do not edit directly.'); - lines.push(''); + const nodes: ts.Node[] = []; // Import referenced enums from @app/enums if (referencedEnums.size > 0) { - const enumImports = Array.from(referencedEnums).sort().join(', '); - lines.push(`import type { ${enumImports} } from '@app/enums';`); - lines.push(''); + const enumImports = Array.from(referencedEnums).sort(); + nodes.push(createImportType(enumImports, '@app/enums')); } // Generate resource types @@ -271,23 +281,31 @@ export function generateResourceTypeScript( const fields = resources[className]; if (fallbacks.includes(className)) { - lines.push(`export type ${className} = Record;`); - lines.push(''); + // Fallback type: Record + const recordType = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier('Record'), [ + ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), + ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), + ]); + nodes.push(createTypeAlias(className, recordType)); continue; } - lines.push(`export type ${className} = {`); - for (const key of Object.keys(fields)) { + // Create type literal with all fields + const properties = Object.keys(fields).map((key) => { const info = fields[key]; - const type = info.type || 'any'; - const optional = info.optional ? '?' : ''; - lines.push(` ${key}${optional}: ${type};`); - } - lines.push('};'); - lines.push(''); + return { + name: key, + type: parseTypeString(info.type || 'any'), + optional: info.optional, + }; + }); + + nodes.push(createTypeAlias(className, createTypeLiteral(properties))); } - return lines.join('\n'); + if (nodes.length === 0) return ''; + + return nodes.map(printNode).join('\n\n') + '\n'; } /** @@ -295,12 +313,7 @@ export function generateResourceTypeScript( * Resources are type-only, so this just exports an empty object. */ export function generateResourceRuntime(): string { - const lines: string[] = []; - lines.push('// Auto-generated by primcloud Vite plugin'); - lines.push('// Resources are type-only exports'); - lines.push(''); - lines.push('export default {};'); - return lines.join('\n'); + return 'export default {};'; } /** From 0dd414e5047eb263012ac161dfdfd24a32bc29ca Mon Sep 17 00:00:00 2001 From: Josh Manders Date: Mon, 5 Jan 2026 20:19:32 -0800 Subject: [PATCH 05/11] feat(plugin): add prettyPrint option for formatted output - Add prettyPrint option to ResourceTypesPluginOptions (default: true) - Pass prettyPrint through to enum and resource generators - Refactor options handling to use nullish coalescing --- src/index.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3ae5cc1..aa9bf03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { setupResourceWatcher } from './watchers/resources.js'; export type ResourceTypesPluginOptions = { cwd?: string; + prettyPrint?: boolean; }; /** @@ -17,22 +18,22 @@ export type ResourceTypesPluginOptions = { * - @app/resources - Laravel JsonResource types * - @app/schemas - (future) Zod schemas from FormRequests */ -export default function ferry( - options: ResourceTypesPluginOptions = { - cwd: process.cwd(), - } -): Plugin { +export default function ferry(options: ResourceTypesPluginOptions = {}): Plugin { const namespace = '@ferry'; const name = 'vite-plugin-ferry'; + // Apply defaults + const cwd = options.cwd ?? process.cwd(); + const prettyPrint = options.prettyPrint ?? true; + // Directory paths - const enumsDir = join(options.cwd, 'app/Enums'); - const resourcesDir = join(options.cwd, 'app/Http/Resources'); - const modelsDir = join(options.cwd, 'app/Models'); + const enumsDir = join(cwd, 'app/Enums'); + const resourcesDir = join(cwd, 'app/Http/Resources'); + const modelsDir = join(cwd, 'app/Models'); // Output directories for each package - const enumsOutputDir = join(options.cwd, 'node_modules', ...namespace.split('/'), 'enums'); - const resourcesOutputDir = join(options.cwd, 'node_modules', ...namespace.split('/'), 'resources'); + const enumsOutputDir = join(cwd, 'node_modules', ...namespace.split('/'), 'enums'); + const resourcesOutputDir = join(cwd, 'node_modules', ...namespace.split('/'), 'resources'); /** * Generate all packages. @@ -43,6 +44,7 @@ export default function ferry( enumsDir, outputDir: enumsOutputDir, packageName: `${namespace}/enums`, + prettyPrint, }); // Generate @app/resources package @@ -52,6 +54,7 @@ export default function ferry( modelsDir, outputDir: resourcesOutputDir, packageName: `${namespace}/resources`, + prettyPrint, }); } From 7cf696f3ce2bb5331b2d309f848a871704b29f30 Mon Sep 17 00:00:00 2001 From: Josh Manders Date: Mon, 5 Jan 2026 20:20:32 -0800 Subject: [PATCH 06/11] test: add comprehensive test suite with vitest - Add vitest as dev dependency with test scripts - Add typescript as peer dependency for code generation - Add php-parser as runtime dependency - Add tests for php-parser (enum parsing, model casts, docblocks) - Add tests for ts-generator (all utility functions) - Add tests for enum generator (TypeScript and runtime output) - Add tests for resource generator (type inference, imports) - Add tests for type-mapper utilities - Include PHP fixture files for integration tests --- package-lock.json | 366 ++++++++++++++++++++- package.json | 11 +- tests/enum-generator.spec.ts | 189 +++++++++++ tests/fixtures/Enums/Color.php | 10 + tests/fixtures/Enums/OrderStatus.php | 21 ++ tests/fixtures/Enums/Priority.php | 11 + tests/fixtures/Enums/Role.php | 10 + tests/fixtures/Models/Comment.php | 17 + tests/fixtures/Models/Order.php | 17 + tests/fixtures/Models/Post.php | 14 + tests/fixtures/Models/User.php | 15 + tests/fixtures/Resources/OrderResource.php | 35 ++ tests/fixtures/Resources/PostResource.php | 23 ++ tests/fixtures/Resources/UserResource.php | 21 ++ tests/php-parser.spec.ts | 269 +++++++++++++++ tests/resource-generator.spec.ts | 334 +++++++++++++++++++ tests/ts-generator.spec.ts | 239 ++++++++++++++ tests/type-mapper.spec.ts | 119 +++++++ tests/utils.ts | 35 ++ vitest.config.ts | 8 + 20 files changed, 1762 insertions(+), 2 deletions(-) create mode 100644 tests/enum-generator.spec.ts create mode 100644 tests/fixtures/Enums/Color.php create mode 100644 tests/fixtures/Enums/OrderStatus.php create mode 100644 tests/fixtures/Enums/Priority.php create mode 100644 tests/fixtures/Enums/Role.php create mode 100644 tests/fixtures/Models/Comment.php create mode 100644 tests/fixtures/Models/Order.php create mode 100644 tests/fixtures/Models/Post.php create mode 100644 tests/fixtures/Models/User.php create mode 100644 tests/fixtures/Resources/OrderResource.php create mode 100644 tests/fixtures/Resources/PostResource.php create mode 100644 tests/fixtures/Resources/UserResource.php create mode 100644 tests/php-parser.spec.ts create mode 100644 tests/resource-generator.spec.ts create mode 100644 tests/ts-generator.spec.ts create mode 100644 tests/type-mapper.spec.ts create mode 100644 tests/utils.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index b9ee899..05902ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,16 @@ "": { "name": "vite-plugin-ferry", "version": "0.1.2", + "dependencies": { + "php-parser": "^3.2.5" + }, "devDependencies": { "@aniftyco/prettier": "^1.3.0", "@types/node": "^24", "prettier": "^3.6.2", "typescript": "^5.9.2", - "vite": "^7.3.0" + "vite": "^7.3.0", + "vitest": "^4.0.16" } }, "node_modules/@aniftyco/prettier": { @@ -988,6 +992,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@trivago/prettier-plugin-sort-imports": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.2.tgz", @@ -1023,6 +1034,24 @@ } } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1040,6 +1069,137 @@ "undici-types": "~7.16.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1058,6 +1218,13 @@ } } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1100,6 +1267,26 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1167,6 +1354,16 @@ "dev": true, "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1193,6 +1390,30 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/php-parser": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/php-parser/-/php-parser-3.2.5.tgz", + "integrity": "sha512-M1ZYlALFFnESbSdmRtTQrBFUHSriHgPhgqtTF/LCbZM4h7swR5PHtUceB2Kzby5CfqcsYwBn7OXTJ0+8Sajwkw==", + "license": "BSD-3-Clause" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1403,6 +1624,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1413,6 +1641,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1430,6 +1689,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1525,6 +1794,101 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index e0e13e8..9fe40a3 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,22 @@ "scripts": { "start": "tsc --watch", "build": "rm -rf dist/ && tsc", + "test": "vitest run", + "test:watch": "vitest", "prepublishOnly": "rm -rf dist/ && tsc" }, + "dependencies": { + "php-parser": "^3.2.5" + }, "devDependencies": { "@aniftyco/prettier": "^1.3.0", "@types/node": "^24", "prettier": "^3.6.2", "typescript": "^5.9.2", - "vite": "^7.3.0" + "vite": "^7.3.0", + "vitest": "^4.0.16" + }, + "peerDependencies": { + "typescript": "^5.0.0" } } diff --git a/tests/enum-generator.spec.ts b/tests/enum-generator.spec.ts new file mode 100644 index 0000000..809d270 --- /dev/null +++ b/tests/enum-generator.spec.ts @@ -0,0 +1,189 @@ +import { join } from 'node:path'; +import { describe, it, expect } from 'vitest'; +import { generateEnumTypeScript, generateEnumRuntime, collectEnums } from '../src/generators/enums.js'; +import type { EnumDefinition } from '../src/utils/php-parser.js'; +import { dedent } from './utils.js'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); + +describe('collectEnums', () => { + it('collects all enums from directory', () => { + const enums = collectEnums(join(fixturesDir, 'Enums')); + + expect(Object.keys(enums).sort()).toEqual(['Color', 'OrderStatus', 'Priority', 'Role']); + }); + + it('returns empty object for non-existent directory', () => { + const enums = collectEnums('/non/existent/directory'); + expect(enums).toEqual({}); + }); +}); + +describe('generateEnumTypeScript', () => { + it('generates const declaration for enum with labels', () => { + const enums: Record = { + OrderStatus: { + name: 'OrderStatus', + backing: 'string', + cases: [ + { key: 'PENDING', value: 'pending', label: 'Pending Order' }, + { key: 'APPROVED', value: 'approved', label: 'Approved' }, + ], + }, + }; + + const result = generateEnumTypeScript(enums); + + expect(result).toBe(dedent` + export declare const OrderStatus: { + PENDING: { + value: "pending"; + label: "Pending Order"; + }; + APPROVED: { + value: "approved"; + label: "Approved"; + }; + }; + `); + }); + + it('generates traditional enum for enum without labels', () => { + const enums: Record = { + Role: { + name: 'Role', + backing: 'string', + cases: [ + { key: 'ADMIN', value: 'admin' }, + { key: 'USER', value: 'user' }, + ], + }, + }; + + const result = generateEnumTypeScript(enums); + + expect(result).toBe(dedent` + export enum Role { + ADMIN = "admin", + USER = "user" + } + `); + }); + + it('generates numeric values for int-backed enums', () => { + const enums: Record = { + Priority: { + name: 'Priority', + backing: 'int', + cases: [ + { key: 'LOW', value: 1 }, + { key: 'HIGH', value: 3 }, + ], + }, + }; + + const result = generateEnumTypeScript(enums); + + expect(result).toBe(dedent` + export enum Priority { + LOW = 1, + HIGH = 3 + } + `); + }); + + it('generates empty string when no enums', () => { + const enums: Record = {}; + const result = generateEnumTypeScript(enums); + + expect(result).toBe(''); + }); +}); + +describe('generateEnumRuntime', () => { + it('generates runtime object for enum with labels', () => { + const enums: Record = { + OrderStatus: { + name: 'OrderStatus', + backing: 'string', + cases: [{ key: 'PENDING', value: 'pending', label: 'Pending Order' }], + }, + }; + + const result = generateEnumRuntime(enums); + + expect(result).toBe(dedent` + export const OrderStatus = { + PENDING: { + value: "pending", + label: "Pending Order" + } + }; + + export default {}; + `); + }); + + it('generates runtime object for enum without labels', () => { + const enums: Record = { + Role: { + name: 'Role', + backing: 'string', + cases: [ + { key: 'ADMIN', value: 'admin' }, + { key: 'USER', value: 'user' }, + ], + }, + }; + + const result = generateEnumRuntime(enums); + + expect(result).toBe(dedent` + export const Role = { + ADMIN: "admin", + USER: "user" + }; + + export default {}; + `); + }); + + it('exports default empty object when no enums', () => { + const enums: Record = {}; + const result = generateEnumRuntime(enums); + + expect(result).toBe('export default {};\n'); + }); +}); + +describe('enum generation integration', () => { + it('collects and generates complete enum output', () => { + const enums = collectEnums(join(fixturesDir, 'Enums')); + const typescript = generateEnumTypeScript(enums); + const runtime = generateEnumRuntime(enums); + + // Check TypeScript declarations contain expected enums + expect(typescript).toContain('export enum Color'); + expect(typescript).toContain('RED = "RED"'); + expect(typescript).toContain('export declare const OrderStatus'); + expect(typescript).toContain('PENDING'); + expect(typescript).toContain('"pending"'); + expect(typescript).toContain('export enum Priority'); + expect(typescript).toContain('LOW = 1'); + expect(typescript).toContain('export enum Role'); + expect(typescript).toContain('ADMIN = "admin"'); + + // Check runtime contains expected exports + expect(runtime).toContain('export const Color'); + expect(runtime).toContain('RED: "RED"'); + expect(runtime).toContain('export const OrderStatus'); + expect(runtime).toContain('PENDING: {'); + expect(runtime).toContain('value: "pending"'); + expect(runtime).toContain('label: "Pending Order"'); + expect(runtime).toContain('export const Priority'); + expect(runtime).toContain('LOW: 1'); + expect(runtime).toContain('export const Role'); + expect(runtime).toContain('ADMIN: "admin"'); + expect(runtime).toContain('export default {}'); + }); +}); diff --git a/tests/fixtures/Enums/Color.php b/tests/fixtures/Enums/Color.php new file mode 100644 index 0000000..1216500 --- /dev/null +++ b/tests/fixtures/Enums/Color.php @@ -0,0 +1,10 @@ + 'Pending Order', + self::APPROVED => 'Approved', + self::REJECTED => 'Rejected', + self::SHIPPED => 'Shipped', + }; + } +} diff --git a/tests/fixtures/Enums/Priority.php b/tests/fixtures/Enums/Priority.php new file mode 100644 index 0000000..922e213 --- /dev/null +++ b/tests/fixtures/Enums/Priority.php @@ -0,0 +1,11 @@ + 'boolean', + 'approved_at' => 'datetime', + 'likes_count' => 'integer', + ]; + } +} diff --git a/tests/fixtures/Models/Order.php b/tests/fixtures/Models/Order.php new file mode 100644 index 0000000..7f19b69 --- /dev/null +++ b/tests/fixtures/Models/Order.php @@ -0,0 +1,17 @@ + OrderStatus::class, + 'total' => 'decimal', + 'items' => 'json', + 'shipped_at' => 'datetime', + 'is_gift' => 'boolean', + ]; +} diff --git a/tests/fixtures/Models/Post.php b/tests/fixtures/Models/Post.php new file mode 100644 index 0000000..56d61ec --- /dev/null +++ b/tests/fixtures/Models/Post.php @@ -0,0 +1,14 @@ + 'boolean', + 'published_at' => 'datetime', + 'metadata' => 'array', + ]; +} diff --git a/tests/fixtures/Models/User.php b/tests/fixtures/Models/User.php new file mode 100644 index 0000000..f98e34f --- /dev/null +++ b/tests/fixtures/Models/User.php @@ -0,0 +1,15 @@ + 'datetime', + 'is_admin' => 'boolean', + 'settings' => 'array', + 'password' => 'hashed', + ]; +} diff --git a/tests/fixtures/Resources/OrderResource.php b/tests/fixtures/Resources/OrderResource.php new file mode 100644 index 0000000..204fc14 --- /dev/null +++ b/tests/fixtures/Resources/OrderResource.php @@ -0,0 +1,35 @@ + $this->resource->id, + 'total' => $this->resource->total, + 'status' => $this->resource->status, + 'items' => $this->resource->items, + 'user' => $this->whenLoaded('user'), + 'shipping_address' => [ + 'street' => $this->resource->address_street, + 'city' => $this->resource->address_city, + 'zip' => $this->resource->address_zip, + ], + 'created_at' => $this->resource->created_at, + ]; + } +} diff --git a/tests/fixtures/Resources/PostResource.php b/tests/fixtures/Resources/PostResource.php new file mode 100644 index 0000000..c209e83 --- /dev/null +++ b/tests/fixtures/Resources/PostResource.php @@ -0,0 +1,23 @@ + $this->resource->id, + 'title' => $this->resource->title, + 'slug' => $this->resource->slug, + 'is_published' => $this->resource->is_published, + 'has_comments' => $this->resource->has_comments, + 'author' => UserResource::collection($this->whenLoaded('author')), + 'comments' => CommentResource::collection($this->whenLoaded('comments')), + 'created_at' => $this->resource->created_at, + ]; + } +} diff --git a/tests/fixtures/Resources/UserResource.php b/tests/fixtures/Resources/UserResource.php new file mode 100644 index 0000000..40f46ab --- /dev/null +++ b/tests/fixtures/Resources/UserResource.php @@ -0,0 +1,21 @@ + $this->resource->id, + 'name' => $this->resource->name, + 'email' => $this->resource->email, + 'is_admin' => $this->resource->is_admin, + 'created_at' => $this->resource->created_at, + 'updated_at' => $this->resource->updated_at, + ]; + } +} diff --git a/tests/php-parser.spec.ts b/tests/php-parser.spec.ts new file mode 100644 index 0000000..1ae08a7 --- /dev/null +++ b/tests/php-parser.spec.ts @@ -0,0 +1,269 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + parseEnumContent, + parseModelCasts, + extractDocblockArrayShape, + extractReturnArrayBlock, +} from '../src/utils/php-parser.js'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); + +function readFixture(path: string): string { + return readFileSync(join(fixturesDir, path), 'utf8'); +} + +describe('parseEnumContent', () => { + it('parses enum with string backing and labels', () => { + const content = readFixture('Enums/OrderStatus.php'); + const result = parseEnumContent(content); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('OrderStatus'); + expect(result!.backing).toBe('string'); + expect(result!.cases).toHaveLength(4); + + const pending = result!.cases.find((c) => c.key === 'PENDING'); + expect(pending).toBeDefined(); + expect(pending!.value).toBe('pending'); + expect(pending!.label).toBe('Pending Order'); + }); + + it('parses enum with string backing without labels', () => { + const content = readFixture('Enums/Role.php'); + const result = parseEnumContent(content); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('Role'); + expect(result!.backing).toBe('string'); + expect(result!.cases).toHaveLength(3); + + const admin = result!.cases.find((c) => c.key === 'ADMIN'); + expect(admin).toBeDefined(); + expect(admin!.value).toBe('admin'); + expect(admin!.label).toBeUndefined(); + }); + + it('parses enum with int backing', () => { + const content = readFixture('Enums/Priority.php'); + const result = parseEnumContent(content); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('Priority'); + expect(result!.backing).toBe('int'); + }); + + it('parses unit enum (no backing type)', () => { + const content = readFixture('Enums/Color.php'); + const result = parseEnumContent(content); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('Color'); + expect(result!.backing).toBeNull(); + expect(result!.cases).toHaveLength(3); + + const red = result!.cases.find((c) => c.key === 'RED'); + expect(red).toBeDefined(); + expect(red!.value).toBe('RED'); + }); + + it('returns null for invalid PHP content', () => { + const result = parseEnumContent('not valid php'); + expect(result).toBeNull(); + }); + + it('returns null for PHP without enum', () => { + const result = parseEnumContent(' { + it('parses protected $casts property', () => { + const content = readFixture('Models/User.php'); + const result = parseModelCasts(content); + + expect(result.email_verified_at).toBe('datetime'); + expect(result.is_admin).toBe('boolean'); + expect(result.settings).toBe('array'); + expect(result.password).toBe('hashed'); + }); + + it('parses protected $casts from another model', () => { + const content = readFixture('Models/Post.php'); + const result = parseModelCasts(content); + + expect(result.is_published).toBe('boolean'); + expect(result.published_at).toBe('datetime'); + expect(result.metadata).toBe('array'); + }); + + it('parses casts() method with return type annotation', () => { + const content = readFixture('Models/Comment.php'); + const result = parseModelCasts(content); + + expect(result.is_approved).toBe('boolean'); + expect(result.approved_at).toBe('datetime'); + expect(result.likes_count).toBe('integer'); + }); + + it('parses class-based casts', () => { + const content = readFixture('Models/Order.php'); + const result = parseModelCasts(content); + + expect(result.status).toBe('OrderStatus'); + expect(result.total).toBe('decimal'); + }); + + it('returns empty object for invalid PHP', () => { + const result = parseModelCasts('not valid php'); + expect(result).toEqual({}); + }); + + it('returns empty object for class without casts', () => { + const result = parseModelCasts(' { + it('extracts docblock array shape (inline format)', () => { + const content = `@return array { id: string, name: string, email: string }`; + + const result = extractDocblockArrayShape(content); + + expect(result).not.toBeNull(); + expect(result!.id).toBe('string'); + expect(result!.name).toBe('string'); + expect(result!.email).toBe('string'); + }); + + it('returns null when no docblock shape exists', () => { + const content = ` + public function toArray($request): array + { + return ['id' => $this->id]; + } + `; + + const result = extractDocblockArrayShape(content); + expect(result).toBeNull(); + }); + + it('handles nested types (inline format)', () => { + const content = `@return array { user: array { id: string, name: string }, items: array }`; + + const result = extractDocblockArrayShape(content); + + expect(result).not.toBeNull(); + expect(result!.user).toBe('array { id: string, name: string }'); + expect(result!.items).toBe('array'); + }); + + it('parses multiline docblock with asterisks', () => { + const content = ` + /** + * @return array { + * id: string, + * name: string, + * email: string, + * } + */ + `; + + const result = extractDocblockArrayShape(content); + + expect(result).not.toBeNull(); + expect(result!.id).toBe('string'); + expect(result!.name).toBe('string'); + expect(result!.email).toBe('string'); + }); + + it('extracts docblock from OrderResource fixture', () => { + const content = readFixture('Resources/OrderResource.php'); + const result = extractDocblockArrayShape(content); + + expect(result).not.toBeNull(); + expect(result!.id).toBe('string'); + expect(result!.total).toBe('number'); + expect(result!.status).toBe('string'); + expect(result!.items).toBe('array'); + expect(result!.created_at).toBe('string'); + }); + + it('returns null for resource without docblock', () => { + const content = readFixture('Resources/UserResource.php'); + const result = extractDocblockArrayShape(content); + + expect(result).toBeNull(); + }); +}); + +describe('extractReturnArrayBlock', () => { + it('extracts return array from toArray method', () => { + const content = ` + public function toArray($request): array + { + return [ + 'id' => $this->resource->id, + 'name' => $this->resource->name, + ]; + } + `; + + const result = extractReturnArrayBlock(content); + + expect(result).not.toBeNull(); + expect(result).toContain("'id' => $this->resource->id"); + expect(result).toContain("'name' => $this->resource->name"); + }); + + it('returns null when no toArray method exists', () => { + const content = ` + public function getData(): array + { + return ['id' => $this->id]; + } + `; + + const result = extractReturnArrayBlock(content); + expect(result).toBeNull(); + }); + + it('extracts array block from UserResource fixture', () => { + const content = readFixture('Resources/UserResource.php'); + const result = extractReturnArrayBlock(content); + + expect(result).not.toBeNull(); + expect(result).toContain("'id' => $this->resource->id"); + expect(result).toContain("'name' => $this->resource->name"); + expect(result).toContain("'email' => $this->resource->email"); + expect(result).toContain("'is_admin' => $this->resource->is_admin"); + expect(result).toContain("'created_at' => $this->resource->created_at"); + expect(result).toContain("'updated_at' => $this->resource->updated_at"); + }); + + it('extracts array block from PostResource with relations', () => { + const content = readFixture('Resources/PostResource.php'); + const result = extractReturnArrayBlock(content); + + expect(result).not.toBeNull(); + expect(result).toContain("'id' => $this->resource->id"); + expect(result).toContain('UserResource::collection'); + expect(result).toContain('CommentResource::collection'); + expect(result).toContain("whenLoaded('author')"); + expect(result).toContain("whenLoaded('comments')"); + }); + + it('extracts array block with nested arrays from OrderResource', () => { + const content = readFixture('Resources/OrderResource.php'); + const result = extractReturnArrayBlock(content); + + expect(result).not.toBeNull(); + expect(result).toContain("'shipping_address' => ["); + expect(result).toContain("'street' => $this->resource->address_street"); + expect(result).toContain("'city' => $this->resource->address_city"); + expect(result).toContain("'zip' => $this->resource->address_zip"); + }); +}); diff --git a/tests/resource-generator.spec.ts b/tests/resource-generator.spec.ts new file mode 100644 index 0000000..1a9b649 --- /dev/null +++ b/tests/resource-generator.spec.ts @@ -0,0 +1,334 @@ +import { describe, it, expect } from 'vitest'; +import { + generateResourceTypeScript, + generateResourceRuntime, + parseFieldsFromArrayBlock, + type FieldInfo, +} from '../src/generators/resources.js'; +import { dedent } from './utils.js'; + +describe('generateResourceTypeScript', () => { + it('generates type declarations for resources', () => { + const resources: Record> = { + UserResource: { + id: { type: 'string', optional: false }, + name: { type: 'string', optional: false }, + email: { type: 'string', optional: false }, + is_admin: { type: 'boolean', optional: false }, + }, + }; + + const result = generateResourceTypeScript(resources, [], new Set()); + + expect(result).toBe(dedent` + export type UserResource = { + id: string; + name: string; + email: string; + is_admin: boolean; + }; + `); + }); + + it('handles optional fields', () => { + const resources: Record> = { + PostResource: { + id: { type: 'string', optional: false }, + author: { type: 'UserResource', optional: true }, + }, + }; + + const result = generateResourceTypeScript(resources, [], new Set()); + + expect(result).toBe(dedent` + export type PostResource = { + id: string; + author?: UserResource; + }; + `); + }); + + it('generates fallback Record type for unparseable resources', () => { + const resources: Record> = { + ComplexResource: {}, + }; + + const result = generateResourceTypeScript(resources, ['ComplexResource'], new Set()); + + expect(result).toBe(dedent` + export type ComplexResource = Record; + `); + }); + + it('imports referenced enums', () => { + const resources: Record> = { + OrderResource: { + status: { type: 'OrderStatus', optional: false }, + }, + }; + + const result = generateResourceTypeScript(resources, [], new Set(['OrderStatus'])); + + expect(result).toBe(dedent` + import type { OrderStatus } from "@app/enums"; + + export type OrderResource = { + status: OrderStatus; + }; + `); + }); + + it('generates empty string when no resources', () => { + const resources: Record> = {}; + const result = generateResourceTypeScript(resources, [], new Set()); + + expect(result).toBe(''); + }); + + it('handles nested object types', () => { + const resources: Record> = { + OrderResource: { + shipping_address: { type: '{ street: string; city: string; zip: string }', optional: false }, + }, + }; + + const result = generateResourceTypeScript(resources, [], new Set()); + + expect(result).toBe(dedent` + export type OrderResource = { + shipping_address: { + street: string; + city: string; + zip: string; + }; + }; + `); + }); + + it('handles array types', () => { + const resources: Record> = { + PostResource: { + comments: { type: 'CommentResource[]', optional: true }, + }, + }; + + const result = generateResourceTypeScript(resources, [], new Set()); + + expect(result).toBe(dedent` + export type PostResource = { + comments?: CommentResource[]; + }; + `); + }); + + it('sorts multiple enum imports alphabetically', () => { + const resources: Record> = { + OrderResource: { + status: { type: 'OrderStatus', optional: false }, + priority: { type: 'Priority', optional: false }, + }, + }; + + const result = generateResourceTypeScript(resources, [], new Set(['Priority', 'OrderStatus'])); + + expect(result).toBe(dedent` + import type { OrderStatus, Priority } from "@app/enums"; + + export type OrderResource = { + status: OrderStatus; + priority: Priority; + }; + `); + }); +}); + +describe('generateResourceRuntime', () => { + it('generates empty runtime export', () => { + const result = generateResourceRuntime(); + + expect(result).toBe('export default {};'); + }); +}); + +describe('resource type inference patterns', () => { + it('preserves boolean types for is_ and has_ prefixed fields', () => { + const resources: Record> = { + UserResource: { + is_admin: { type: 'boolean', optional: false }, + is_verified: { type: 'boolean', optional: false }, + has_comments: { type: 'boolean', optional: false }, + }, + }; + + const result = generateResourceTypeScript(resources, [], new Set()); + + expect(result).toBe(dedent` + export type UserResource = { + is_admin: boolean; + is_verified: boolean; + has_comments: boolean; + }; + `); + }); + + it('preserves string types for timestamp fields', () => { + const resources: Record> = { + UserResource: { + created_at: { type: 'string', optional: false }, + updated_at: { type: 'string', optional: false }, + }, + }; + + const result = generateResourceTypeScript(resources, [], new Set()); + + expect(result).toBe(dedent` + export type UserResource = { + created_at: string; + updated_at: string; + }; + `); + }); + + it('preserves string types for id fields', () => { + const resources: Record> = { + UserResource: { + id: { type: 'string', optional: false }, + user_id: { type: 'string', optional: false }, + uuid: { type: 'string', optional: false }, + }, + }; + + const result = generateResourceTypeScript(resources, [], new Set()); + + expect(result).toBe(dedent` + export type UserResource = { + id: string; + user_id: string; + uuid: string; + }; + `); + }); +}); + +describe('parseFieldsFromArrayBlock', () => { + const emptyDirs = { resourcesDir: '', modelsDir: '', enumsDir: '' }; + + it('infers boolean type from is_ prefix', () => { + const block = `'is_admin' => $this->resource->is_admin,`; + const fields = parseFieldsFromArrayBlock(block, 'UserResource', null, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(fields.is_admin).toEqual({ type: 'boolean', optional: false }); + }); + + it('infers boolean type from has_ prefix', () => { + const block = `'has_comments' => $this->resource->has_comments,`; + const fields = parseFieldsFromArrayBlock(block, 'PostResource', null, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(fields.has_comments).toEqual({ type: 'boolean', optional: false }); + }); + + it('infers boolean type from camelCase is/has prefix', () => { + const block = ` + 'isVerified' => $this->resource->isVerified, + 'hasSubscription' => $this->resource->hasSubscription, + `; + const fields = parseFieldsFromArrayBlock(block, 'UserResource', null, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(fields.isVerified).toEqual({ type: 'boolean', optional: false }); + expect(fields.hasSubscription).toEqual({ type: 'boolean', optional: false }); + }); + + it('infers string type from id field', () => { + const block = `'id' => $this->resource->id,`; + const fields = parseFieldsFromArrayBlock(block, 'UserResource', null, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(fields.id).toEqual({ type: 'string', optional: false }); + }); + + it('infers string type from _id suffix', () => { + const block = `'user_id' => $this->resource->user_id,`; + const fields = parseFieldsFromArrayBlock(block, 'PostResource', null, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(fields.user_id).toEqual({ type: 'string', optional: false }); + }); + + it('infers string type from timestamp fields', () => { + const block = ` + 'created_at' => $this->resource->created_at, + 'updated_at' => $this->resource->updated_at, + 'deleted_at' => $this->resource->deleted_at, + `; + const fields = parseFieldsFromArrayBlock(block, 'UserResource', null, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(fields.created_at).toEqual({ type: 'string', optional: false }); + expect(fields.updated_at).toEqual({ type: 'string', optional: false }); + expect(fields.deleted_at).toEqual({ type: 'string', optional: false }); + }); + + it('infers array type from Resource::collection', () => { + const block = `'comments' => CommentResource::collection($this->resource->comments),`; + const fields = parseFieldsFromArrayBlock(block, 'PostResource', null, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(fields.comments).toEqual({ type: 'CommentResource[]', optional: false }); + }); + + it('infers optional array type from Resource::collection with whenLoaded', () => { + const block = `'comments' => CommentResource::collection($this->whenLoaded('comments')),`; + const fields = parseFieldsFromArrayBlock(block, 'PostResource', null, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(fields.comments).toEqual({ type: 'CommentResource[]', optional: true }); + }); + + it('infers optional type from whenLoaded without Resource', () => { + const block = `'author' => $this->whenLoaded('author'),`; + const fields = parseFieldsFromArrayBlock(block, 'PostResource', null, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(fields.author.optional).toBe(true); + }); + + it('parses nested arrays as inline object types', () => { + const block = ` + 'address' => [ + 'street' => $this->resource->street, + 'city' => $this->resource->city, + ], + `; + const fields = parseFieldsFromArrayBlock(block, 'UserResource', null, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(fields.address.type).toBe('{ street: string; city: string }'); + }); + + it('uses docblock type hints when available', () => { + const block = `'count' => $this->resource->count,`; + const docShape = { count: 'number' }; + const fields = parseFieldsFromArrayBlock(block, 'StatsResource', docShape, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(fields.count).toEqual({ type: 'number', optional: false }); + }); + + it('skips comment lines', () => { + const block = ` + // This is a comment + 'id' => $this->resource->id, + `; + const fields = parseFieldsFromArrayBlock(block, 'UserResource', null, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(Object.keys(fields)).toEqual(['id']); + }); + + it('handles multiple fields', () => { + const block = ` + 'id' => $this->resource->id, + 'name' => $this->resource->name, + 'email' => $this->resource->email, + 'is_admin' => $this->resource->is_admin, + `; + const fields = parseFieldsFromArrayBlock(block, 'UserResource', null, emptyDirs.resourcesDir, emptyDirs.modelsDir, emptyDirs.enumsDir, {}); + + expect(Object.keys(fields)).toEqual(['id', 'name', 'email', 'is_admin']); + expect(fields.id.type).toBe('string'); + expect(fields.name.type).toBe('string'); + expect(fields.email.type).toBe('string'); + expect(fields.is_admin.type).toBe('boolean'); + }); +}); diff --git a/tests/ts-generator.spec.ts b/tests/ts-generator.spec.ts new file mode 100644 index 0000000..f8c69db --- /dev/null +++ b/tests/ts-generator.spec.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from 'vitest'; +import { + printNode, + printNodes, + createStringLiteral, + createNumericLiteral, + createEnum, + createObjectLiteral, + createConstObject, + createTypeAlias, + createTypeLiteral, + createImportType, + parseTypeString, + createExportDefault, +} from '../src/utils/ts-generator.js'; +import { dedent } from './utils.js'; + +describe('printNode', () => { + it('prints a string literal', () => { + const node = createStringLiteral('hello'); + expect(printNode(node)).toBe('"hello"'); + }); + + it('prints a numeric literal', () => { + const node = createNumericLiteral(42); + expect(printNode(node)).toBe('42'); + }); +}); + +describe('printNodes', () => { + it('joins multiple nodes with blank lines', () => { + const nodes = [createStringLiteral('a'), createStringLiteral('b')]; + expect(printNodes(nodes)).toBe('"a"\n\n"b"'); + }); +}); + +describe('createEnum', () => { + it('creates string enum declaration', () => { + const node = createEnum('Role', [ + { key: 'ADMIN', value: 'admin' }, + { key: 'USER', value: 'user' }, + ]); + + expect(printNode(node)).toBe( + dedent` + export enum Role { + ADMIN = "admin", + USER = "user" + } + `.trimEnd() + ); + }); + + it('creates numeric enum declaration', () => { + const node = createEnum('Priority', [ + { key: 'LOW', value: 1 }, + { key: 'HIGH', value: 3 }, + ]); + + expect(printNode(node)).toBe( + dedent` + export enum Priority { + LOW = 1, + HIGH = 3 + } + `.trimEnd() + ); + }); +}); + +describe('createObjectLiteral', () => { + it('creates multi-line object literal when multiLine is true', () => { + const node = createObjectLiteral( + [ + { key: 'value', value: createStringLiteral('pending') }, + { key: 'label', value: createStringLiteral('Pending Order') }, + ], + true + ); + + expect(printNode(node)).toBe( + dedent` + { + value: "pending", + label: "Pending Order" + } + `.trimEnd() + ); + }); + + it('creates single-line object literal when multiLine is false', () => { + const node = createObjectLiteral( + [ + { key: 'value', value: createStringLiteral('pending') }, + { key: 'label', value: createStringLiteral('Pending Order') }, + ], + false + ); + + expect(printNode(node)).toBe('{ value: "pending", label: "Pending Order" }'); + }); + + it('defaults to multi-line', () => { + const node = createObjectLiteral([{ key: 'foo', value: createStringLiteral('bar') }]); + + expect(printNode(node)).toBe( + dedent` + { + foo: "bar" + } + `.trimEnd() + ); + }); +}); + +describe('createConstObject', () => { + it('creates exported const with object literal', () => { + const node = createConstObject('Config', [ + { key: 'name', value: createStringLiteral('app') }, + { key: 'version', value: createNumericLiteral(1) }, + ]); + + expect(printNode(node)).toBe( + dedent` + export const Config = { + name: "app", + version: 1 + }; + `.trimEnd() + ); + }); + + it('respects multiLine parameter', () => { + const node = createConstObject( + 'Config', + [ + { key: 'a', value: createStringLiteral('1') }, + { key: 'b', value: createStringLiteral('2') }, + ], + false + ); + + expect(printNode(node)).toBe('export const Config = { a: "1", b: "2" };'); + }); +}); + +describe('createTypeAlias', () => { + it('creates type alias with type literal', () => { + const typeLiteral = createTypeLiteral([ + { name: 'id', type: parseTypeString('string') }, + { name: 'name', type: parseTypeString('string') }, + ]); + const node = createTypeAlias('User', typeLiteral); + + expect(printNode(node)).toBe( + dedent` + export type User = { + id: string; + name: string; + }; + `.trimEnd() + ); + }); + + it('handles optional properties', () => { + const typeLiteral = createTypeLiteral([ + { name: 'id', type: parseTypeString('string') }, + { name: 'email', type: parseTypeString('string'), optional: true }, + ]); + const node = createTypeAlias('User', typeLiteral); + + expect(printNode(node)).toBe( + dedent` + export type User = { + id: string; + email?: string; + }; + `.trimEnd() + ); + }); +}); + +describe('createImportType', () => { + it('creates type-only import declaration', () => { + const node = createImportType(['UserResource', 'PostResource'], '@app/resources'); + + expect(printNode(node)).toBe('import type { UserResource, PostResource } from "@app/resources";'); + }); + + it('creates single type import', () => { + const node = createImportType(['OrderStatus'], '@app/enums'); + + expect(printNode(node)).toBe('import type { OrderStatus } from "@app/enums";'); + }); +}); + +describe('parseTypeString', () => { + it('parses primitive types', () => { + expect(printNode(parseTypeString('string'))).toBe('string'); + expect(printNode(parseTypeString('number'))).toBe('number'); + expect(printNode(parseTypeString('boolean'))).toBe('boolean'); + expect(printNode(parseTypeString('any'))).toBe('any'); + expect(printNode(parseTypeString('null'))).toBe('null'); + expect(printNode(parseTypeString('undefined'))).toBe('undefined'); + }); + + it('parses array types', () => { + expect(printNode(parseTypeString('string[]'))).toBe('string[]'); + expect(printNode(parseTypeString('User[]'))).toBe('User[]'); + }); + + it('parses union types', () => { + expect(printNode(parseTypeString('string | null'))).toBe('string | null'); + expect(printNode(parseTypeString('string | number | boolean'))).toBe('string | number | boolean'); + }); + + it('parses Record types', () => { + expect(printNode(parseTypeString('Record'))).toBe('Record'); + expect(printNode(parseTypeString('Record'))).toBe('Record'); + }); + + it('parses inline object types', () => { + const result = printNode(parseTypeString('{ id: string; name: string }')); + expect(result).toContain('id: string'); + expect(result).toContain('name: string'); + }); + + it('parses custom type references', () => { + expect(printNode(parseTypeString('UserResource'))).toBe('UserResource'); + expect(printNode(parseTypeString('OrderStatus'))).toBe('OrderStatus'); + }); +}); + +describe('createExportDefault', () => { + it('creates export default statement', () => { + const node = createExportDefault(createObjectLiteral([], false)); + expect(printNode(node)).toBe('export default {};'); + }); +}); diff --git a/tests/type-mapper.spec.ts b/tests/type-mapper.spec.ts new file mode 100644 index 0000000..96e079a --- /dev/null +++ b/tests/type-mapper.spec.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { mapPhpTypeToTs, mapDocTypeToTs, parseTsObjectStringToPairs } from '../src/utils/type-mapper.js'; + +describe('mapPhpTypeToTs', () => { + it('maps integer types to number', () => { + expect(mapPhpTypeToTs('int')).toBe('number'); + expect(mapPhpTypeToTs('integer')).toBe('number'); + expect(mapPhpTypeToTs('INT')).toBe('number'); + }); + + it('maps float types to number', () => { + expect(mapPhpTypeToTs('float')).toBe('number'); + expect(mapPhpTypeToTs('double')).toBe('number'); + expect(mapPhpTypeToTs('real')).toBe('number'); + expect(mapPhpTypeToTs('decimal')).toBe('number'); + }); + + it('maps string type', () => { + expect(mapPhpTypeToTs('string')).toBe('string'); + expect(mapPhpTypeToTs('STRING')).toBe('string'); + }); + + it('maps boolean types', () => { + expect(mapPhpTypeToTs('bool')).toBe('boolean'); + expect(mapPhpTypeToTs('boolean')).toBe('boolean'); + }); + + it('maps array types', () => { + expect(mapPhpTypeToTs('array')).toBe('any[]'); + expect(mapPhpTypeToTs('json')).toBe('any[]'); + }); + + it('maps datetime types to string', () => { + expect(mapPhpTypeToTs('datetime')).toBe('string'); + expect(mapPhpTypeToTs('date')).toBe('string'); + expect(mapPhpTypeToTs('immutable_datetime')).toBe('string'); + expect(mapPhpTypeToTs('immutable_date')).toBe('string'); + }); + + it('returns any for unknown types', () => { + expect(mapPhpTypeToTs('unknown')).toBe('any'); + expect(mapPhpTypeToTs('custom')).toBe('any'); + }); +}); + +describe('mapDocTypeToTs', () => { + it('handles nullable types', () => { + expect(mapDocTypeToTs('?string')).toBe('string | null'); + expect(mapDocTypeToTs('?int')).toBe('number | null'); + }); + + it('handles union types', () => { + expect(mapDocTypeToTs('string|null')).toBe('string | null'); + expect(mapDocTypeToTs('int|string')).toBe('number | string'); + }); + + it('handles array notation', () => { + expect(mapDocTypeToTs('Foo[]')).toBe('Foo[]'); + expect(mapDocTypeToTs('User[]')).toBe('User[]'); + }); + + it('handles generic array types', () => { + expect(mapDocTypeToTs('array')).toBe('Foo[]'); + expect(mapDocTypeToTs('array')).toBe('User[]'); + }); + + it('handles mixed type', () => { + expect(mapDocTypeToTs('mixed')).toBe('any'); + }); + + it('handles object types', () => { + expect(mapDocTypeToTs('object')).toBe('Record'); + expect(mapDocTypeToTs('stdClass')).toBe('Record'); + }); + + it('handles array shapes', () => { + const result = mapDocTypeToTs('array { id: string, name: string }'); + expect(result).toBe('{ id: string; name: string }'); + }); + + it('handles nullable array shapes', () => { + const result = mapDocTypeToTs('?array { id: string }'); + expect(result).toBe('{ id: string } | null'); + }); + + it('preserves Record types', () => { + expect(mapDocTypeToTs('Record')).toBe('Record'); + }); +}); + +describe('parseTsObjectStringToPairs', () => { + it('parses simple object string', () => { + const result = parseTsObjectStringToPairs('{ id: string; name: string }'); + expect(result).toEqual({ id: 'string', name: 'string' }); + }); + + it('parses object with optional fields', () => { + const result = parseTsObjectStringToPairs('{ id: string; name?: string }'); + expect(result).toEqual({ id: 'string', name: 'string' }); + }); + + it('parses object with complex types', () => { + const result = parseTsObjectStringToPairs('{ items: User[]; meta: { total: number } }'); + expect(result).toEqual({ + items: 'User[]', + meta: '{ total: number }', + }); + }); + + it('returns empty object for invalid input', () => { + expect(parseTsObjectStringToPairs('not an object')).toEqual({}); + expect(parseTsObjectStringToPairs('')).toEqual({}); + }); + + it('handles comma separators', () => { + const result = parseTsObjectStringToPairs('{ id: string, name: string }'); + expect(result).toEqual({ id: 'string', name: 'string' }); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..3b823d7 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,35 @@ +/** + * Strips leading whitespace from a multi-line string based on the minimum indentation. + * Useful for writing expected output inline with readable indentation. + * + * Usage: dedent`your indented string here` + */ +export function dedent(strings: TemplateStringsArray, ...values: unknown[]): string { + // Reconstruct the string from template parts + let str = strings[0]; + for (let i = 0; i < values.length; i++) { + str += String(values[i]) + strings[i + 1]; + } + + // Remove leading newline if present + let lines = str.replace(/^\n/, '').split('\n'); + + // Remove trailing indentation line (non-empty whitespace before closing backtick) + // But preserve intentionally empty lines (length === 0) + const lastLine = lines[lines.length - 1]; + if (lines.length > 0 && lastLine.length > 0 && lastLine.trim() === '') { + lines = lines.slice(0, -1); + } + + // Find minimum indentation (ignoring empty lines) + const minIndent = lines + .filter((line) => line.trim().length > 0) + .reduce((min, line) => { + const match = line.match(/^(\s*)/); + const indent = match ? match[1].length : 0; + return Math.min(min, indent); + }, Infinity); + + // Remove that indentation from all lines and add trailing newline + return lines.map((line) => (line.length >= minIndent ? line.slice(minIndent) : line)).join('\n') + '\n'; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..989cdb1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['tests/**/*.spec.ts'], + }, +}); From aff7b4015efaaca93a2256154d86305b69e3b742 Mon Sep 17 00:00:00 2001 From: Josh Manders Date: Mon, 5 Jan 2026 20:39:41 -0800 Subject: [PATCH 07/11] task: add test workflow --- .github/workflows/test.yaml | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..4bbfb43 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,38 @@ +name: Test Ferry + +on: + pull_request: + branches: [master] + paths-ignore: + - "**/*.md" + push: + branches: [master] + paths-ignore: + - "**/*.md" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test From 66a2db20a44563b04a99e5f9ac0042a9041e7948 Mon Sep 17 00:00:00 2001 From: Josh Manders Date: Mon, 5 Jan 2026 20:51:42 -0800 Subject: [PATCH 08/11] BREAKING: change getLabel -> label --- src/utils/php-parser.ts | 10 +++++----- tests/fixtures/Enums/OrderStatus.php | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/utils/php-parser.ts b/src/utils/php-parser.ts index 128309a..ce53144 100644 --- a/src/utils/php-parser.ts +++ b/src/utils/php-parser.ts @@ -166,16 +166,16 @@ export function parseEnumContent(phpContent: string): EnumDefinition | null { cases.push({ key, value }); } - // Parse getLabel() method if it exists + // Parse label() method if it exists const methods = findAllNodesByKind(enumNode, 'method') as PhpParserTypes.Method[]; - const getLabelMethod = methods.find((m) => { + const labelMethod = methods.find((m) => { const methodName = typeof m.name === 'string' ? m.name : (m.name as PhpParserTypes.Identifier).name; - return methodName === 'getLabel'; + return methodName === 'label'; }); - if (getLabelMethod && getLabelMethod.body) { + if (labelMethod && labelMethod.body) { // Find match expression in the method - const matchNode = findNodeByKind(getLabelMethod.body, 'match') as PhpParserTypes.Match | null; + const matchNode = findNodeByKind(labelMethod.body, 'match') as PhpParserTypes.Match | null; if (matchNode && matchNode.arms) { for (const arm of matchNode.arms) { if (arm.conds) { diff --git a/tests/fixtures/Enums/OrderStatus.php b/tests/fixtures/Enums/OrderStatus.php index 0bad0cf..8c3dbe9 100644 --- a/tests/fixtures/Enums/OrderStatus.php +++ b/tests/fixtures/Enums/OrderStatus.php @@ -9,7 +9,7 @@ enum OrderStatus: string case REJECTED = 'rejected'; case SHIPPED = 'shipped'; - public function getLabel(): string + public function label(): string { return match($this) { self::PENDING => 'Pending Order', From 1564e1d76b4a65137f794eafdda10a40024996ed Mon Sep 17 00:00:00 2001 From: Josh Manders Date: Mon, 5 Jan 2026 20:52:55 -0800 Subject: [PATCH 09/11] docs: add examples --- README.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9667942..c87721d 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,14 @@ in sync with your backend. - **Enums** — Generates types and runtime constants from `app/Enums/` - **Resources** — Generates response types from `app/Http/Resources/` +## Requirements + +- **TypeScript ^5.0** — Required as a peer dependency for code generation + ## Installation ```bash -npm install vite-plugin-ferry --save-dev +npm install vite-plugin-ferry typescript@^5 --save-dev ``` Add it to your `vite.config.ts`: @@ -33,7 +37,77 @@ Import your backend types directly in your frontend code: ```ts import { OrderStatus } from '@ferry/enums'; -import { UserResource } from '@ferry/resources'; +import type { UserResource } from '@ferry/resources'; +``` + +## Examples + +### Enums + +A PHP enum with labels: + +```php +// app/Enums/OrderStatus.php +enum OrderStatus: string +{ + case Pending = 'pending'; + case Shipped = 'shipped'; + case Delivered = 'delivered'; + + public function label(): string + { + return match ($this) { + self::Pending => 'Pending Order', + self::Shipped => 'Shipped', + self::Delivered => 'Delivered', + }; + } +} +``` + +Generates: + +```ts +// @ferry/enums +export declare const OrderStatus: { + Pending: { value: 'pending'; label: 'Pending Order' }; + Shipped: { value: 'shipped'; label: 'Shipped' }; + Delivered: { value: 'delivered'; label: 'Delivered' }; +}; +``` + +### Resources + +A Laravel JsonResource: + +```php +// app/Http/Resources/UserResource.php +class UserResource extends JsonResource +{ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'name' => $this->resource->name, + 'email' => $this->resource->email, + 'created_at' => $this->resource->created_at, + 'posts' => PostResource::collection($this->whenLoaded('posts')), + ]; + } +} +``` + +Generates: + +```ts +// @ferry/resources +export type UserResource = { + id: string; + name: string; + email: string; + created_at: string; + posts?: PostResource[]; +}; ``` ## License From de26367d9d258410b58a7e5be1c7da319a6dbf83 Mon Sep 17 00:00:00 2001 From: Josh Manders Date: Mon, 5 Jan 2026 21:13:26 -0800 Subject: [PATCH 10/11] task: add publish workflow --- .github/workflows/publish.yaml | 22 ++++++++++++++++++++++ package.json | 3 +-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish.yaml diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..c0f9e07 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,22 @@ +name: Publish + +on: + push: + tags: ["v*"] +permissions: + id-token: write + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npm test + - run: npm run build + - run: npm publish --provenance --access public diff --git a/package.json b/package.json index 9fe40a3..0d9d683 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,7 @@ "start": "tsc --watch", "build": "rm -rf dist/ && tsc", "test": "vitest run", - "test:watch": "vitest", - "prepublishOnly": "rm -rf dist/ && tsc" + "test:watch": "vitest" }, "dependencies": { "php-parser": "^3.2.5" From ffb7b2b19f529b86a9193a44c4c30c592ffa2385 Mon Sep 17 00:00:00 2001 From: Josh Manders Date: Mon, 5 Jan 2026 21:13:59 -0800 Subject: [PATCH 11/11] task: change name --- .github/workflows/publish.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index c0f9e07..d5ee24c 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,4 +1,4 @@ -name: Publish +name: Publish Ferry on: push: