├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── config └── fusion.php ├── database └── migrations │ └── 2025_01_22_192044_create_components_table.php ├── functions.php ├── packages └── vue │ ├── ActionFactory.js │ ├── Cleanup.js │ ├── Database.js │ ├── Pipeline.js │ ├── Transformer.js │ ├── actions │ ├── applyServerState.js │ ├── index.js │ ├── log.js │ ├── logStack.js │ └── syncQueryString.js │ ├── hmr.js │ ├── injectors │ ├── createScriptSetup.js │ ├── index.js │ ├── modifyScriptSetup.js │ ├── optionsWithSetup.js │ ├── optionsWithoutSetup.js │ └── utils.js │ ├── lib │ └── replaceBlock.js │ ├── package-lock.json │ ├── package.json │ ├── scripts │ └── install.js │ ├── testExtractor.js │ ├── tests │ ├── injectors │ │ ├── modifyScriptSetup.test.js │ │ └── optionsWithSetup.test.js │ └── shared.js │ ├── vite.js │ └── vue.js ├── pint.json ├── src ├── Attributes │ ├── Expose.php │ ├── IsReadOnly.php │ ├── Middleware.php │ ├── ServerOnly.php │ └── SyncQueryString.php ├── Blade │ └── Vue │ │ └── page.blade.php ├── Casting │ ├── CasterInterface.php │ ├── CasterRegistry.php │ ├── Casters │ │ ├── BuiltinScalarCaster.php │ │ └── DateTimeCaster.php │ ├── JavaScriptVariable.php │ └── Transportable.php ├── Concerns │ ├── BootsTraits.php │ ├── ComputesState.php │ ├── HandlesExposedActions.php │ ├── InteractsWithQueryStrings.php │ └── IsProceduralPage.php ├── Conformity │ ├── Conformer.php │ └── Transformers │ │ ├── ActionDiscoveryTransformer.php │ │ ├── AnonymousClassTransformer.php │ │ ├── AnonymousReturnTransformer.php │ │ ├── ExposeTransformer.php │ │ ├── FunctionImportTransformer.php │ │ ├── MountTransformer.php │ │ ├── ProceduralTransformer.php │ │ ├── PropDiscoveryTransformer.php │ │ ├── PropTransformer.php │ │ ├── PropValueTransformer.php │ │ ├── Transformer.php │ │ └── TransformerInterface.php ├── Console │ ├── Actions │ │ ├── AddViteConfig.php │ │ ├── AddVuePackage.php │ │ ├── AddVuePlugin.php │ │ ├── ModifyComposer.php │ │ └── RunPackageInstall.php │ └── Commands │ │ ├── Config.php │ │ ├── Conform.php │ │ ├── Install.php │ │ ├── Mirror.php │ │ ├── PostInstall.php │ │ └── Shim.php ├── Enums │ └── Frontend.php ├── Fusion.php ├── FusionManager.php ├── FusionPage.php ├── Http │ ├── Controllers │ │ ├── FusionController.php │ │ └── HmrController.php │ ├── Middleware │ │ ├── MergeStateIntoActionResponse.php │ │ ├── RouteBinding.php │ │ ├── RouteBindingForAction.php │ │ └── RouteBindingForPage.php │ ├── Request │ │ ├── RequestHelper.php │ │ └── RunsSyntheticActions.php │ └── Response │ │ ├── Actions │ │ ├── ApplyServerState.php │ │ ├── Log.php │ │ ├── LogStack.php │ │ ├── ResponseAction.php │ │ └── SyncQueryString.php │ │ └── PendingResponse.php ├── Models │ └── Component.php ├── Providers │ └── FusionServiceProvider.php ├── Reflection │ ├── FusionPageReflection.php │ ├── ReflectionClass.php │ ├── ReflectionCollection.php │ └── Reflector.php ├── Routing │ ├── PendingBind.php │ ├── Registrar.php │ └── SubstituteBindings.php └── Support │ ├── Fluent.php │ └── PendingProp.php └── testbench.yaml /CHANGELOG.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fusion-php/fusion/1355f60ed3897846cf33e31b8d2c06563a563db7/CHANGELOG.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Aaron Francis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fusionphp/fusion", 3 | "description": "A Laravel package to bridge the gap between Laravel and Vue/React.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Aaron Francis", 9 | "email": "aarondfrancis@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8.2", 14 | "illuminate/support": "^11", 15 | "illuminate/console": "^11", 16 | "illuminate/process": "^11", 17 | "laravel/framework": "^11", 18 | "inertiajs/inertia-laravel": "^2.0" 19 | }, 20 | "require-dev": { 21 | "phpunit/phpunit": "^10.5|^11", 22 | "orchestra/testbench": "^9.5", 23 | "nikic/php-parser": "^5.4" 24 | }, 25 | "autoload": { 26 | "files": [ 27 | "functions.php" 28 | ], 29 | "psr-4": { 30 | "Fusion\\": "src/" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Fusion\\Tests\\": "tests/", 36 | "App\\": "workbench/app/" 37 | } 38 | }, 39 | "extra": { 40 | "laravel": { 41 | "providers": [ 42 | "Fusion\\Providers\\FusionServiceProvider" 43 | ] 44 | } 45 | }, 46 | "scripts": { 47 | "post-autoload-dump": [ 48 | "@clear", 49 | "@prepare" 50 | ], 51 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 52 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 53 | "serve": [ 54 | "Composer\\Config::disableProcessTimeout", 55 | "@php vendor/bin/testbench serve --ansi" 56 | ], 57 | "dev": [ 58 | 59 | ] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /config/fusion.php: -------------------------------------------------------------------------------- 1 | Frontend::Vue, 12 | 13 | 'paths' => [ 14 | /* 15 | * The directory to use for generated files. 16 | */ 17 | 'storage' => storage_path('fusion'), 18 | 19 | /* 20 | * The root directory where all your JavaScript lives. 21 | */ 22 | 'js' => resource_path('js'), 23 | 24 | /* 25 | * The directory where your JavaScript Page components live 26 | */ 27 | 'pages' => resource_path('js/Pages'), 28 | ], 29 | 30 | /* 31 | * The base class to use for generated page classes. 32 | * It should be, or extend, FusionPage. 33 | */ 34 | 'base_page' => \Fusion\FusionPage::class, 35 | ]; 36 | -------------------------------------------------------------------------------- /database/migrations/2025_01_22_192044_create_components_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('src')->unique(); 17 | $table->string('php_class')->nullable(); 18 | $table->string('php_path')->nullable(); 19 | $table->string('shim_path')->nullable(); 20 | $table->string('php_hash')->nullable(); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | */ 28 | public function down(): void 29 | { 30 | Schema::dropIfExists('components'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /functions.php: -------------------------------------------------------------------------------- 1 | { 12 | // Create the action function. 13 | const actionFunction = (function () { 14 | let recentlyFailedTimeout; 15 | let recentlySucceededTimeout; 16 | let recentlyFinishedTimeout; 17 | 18 | const status = reactive({ 19 | processing: false, 20 | 21 | failed: false, 22 | recentlyFailed: false, 23 | 24 | succeeded: false, 25 | recentlySucceeded: false, 26 | 27 | finished: false, 28 | recentlyFinished: false, 29 | 30 | error: null, 31 | errors: [], 32 | }); 33 | 34 | const fn = async function (args = {}, body = {}) { 35 | // Reset states before a new request 36 | status.processing = true; 37 | 38 | status.failed = false; 39 | status.recentlyFailed = false; 40 | 41 | status.succeeded = false; 42 | status.recentlySucceeded = false; 43 | 44 | status.finished = false; 45 | status.recentlyFinished = false; 46 | 47 | status.error = null; 48 | status.errors = []; 49 | 50 | clearTimeout(recentlyFailedTimeout); 51 | clearTimeout(recentlySucceededTimeout); 52 | clearTimeout(recentlyFinishedTimeout); 53 | 54 | // If the call comes directly from a Vue template (e.g. 55 | // click or keypress), ignore the event object. 56 | if (args instanceof Event) { 57 | args = {}; 58 | } 59 | 60 | let fusion = { 61 | args, 62 | state: {} 63 | }; 64 | 65 | Object.keys(state).forEach((key) => { 66 | fusion.state[key] = unref(state[key]); 67 | }); 68 | 69 | body.fusion = fusion; 70 | 71 | try { 72 | const response = await axios.post('', body, { 73 | headers: { 74 | 'X-Fusion-Action-Request': 'true', 75 | 'X-Fusion-Action-Handler': key, 76 | } 77 | }); 78 | 79 | // Mark as succeeded 80 | status.succeeded = true; 81 | status.recentlySucceeded = true; 82 | 83 | recentlySucceededTimeout = setTimeout(() => { 84 | status.recentlySucceeded = false; 85 | }, 3500); 86 | 87 | const newState = new Pipeline(response.data?.fusion || {}).createState(); 88 | 89 | Object.keys(newState).forEach((key) => { 90 | if (key in state && isRef(state[key])) { 91 | state[key].value = unref(newState[key]); 92 | } 93 | }); 94 | 95 | return response.data; 96 | } catch (error) { 97 | // If it's a 422, populate the validation errors 98 | if (error.response && error.response.status === 422) { 99 | status.error = error.response.data.message; 100 | status.errors = error.response.data.errors ?? {}; 101 | } 102 | 103 | // Mark as failed 104 | status.failed = true; 105 | status.recentlyFailed = true; 106 | 107 | recentlyFailedTimeout = setTimeout(() => { 108 | status.recentlyFailed = false; 109 | }, 3500); 110 | 111 | throw error; 112 | } finally { 113 | // Mark as finished (both success or fail) 114 | status.finished = true; 115 | status.recentlyFinished = true; 116 | 117 | recentlyFinishedTimeout = setTimeout(() => { 118 | status.recentlyFinished = false; 119 | }, 3500); 120 | 121 | status.processing = false; 122 | } 123 | }; 124 | 125 | // Return a Proxy so that you can call the function directly and also access its reactive status 126 | return new Proxy(fn, { 127 | get(target, prop, receiver) { 128 | if (prop === "getStatus") { 129 | return () => status; 130 | } 131 | 132 | if (Object.prototype.hasOwnProperty.call(status, prop)) { 133 | return status[prop]; 134 | } 135 | 136 | return Reflect.get(target, prop, receiver); 137 | }, 138 | 139 | set(target, prop, value) { 140 | if (Object.prototype.hasOwnProperty.call(status, prop)) { 141 | status[prop] = value; 142 | return true; 143 | } 144 | 145 | return Reflect.set(target, prop, value); 146 | } 147 | }); 148 | })(); 149 | 150 | // If the key starts with 'fusion', add it to fusionProvidedActions with a modified key 151 | if (key.startsWith("fusion")) { 152 | // Remove the 'fusion' prefix and lowercase the first character of the remaining string 153 | const trimmed = key.slice("fusion".length); // e.g. "Sync" from "fusionSync" 154 | const newKey = trimmed.charAt(0).toLowerCase() + trimmed.slice(1); 155 | fusionProvidedActions[newKey] = actionFunction; 156 | } else { 157 | // Save the action using its original key 158 | actions[key] = actionFunction; 159 | } 160 | }); 161 | 162 | // Nest fusionProvidedActions under the `fusion` key in actions 163 | actions.fusion = fusionProvidedActions; 164 | 165 | return actions; 166 | } 167 | } -------------------------------------------------------------------------------- /packages/vue/Cleanup.js: -------------------------------------------------------------------------------- 1 | import Database from "./Database.js"; 2 | import path from 'path'; 3 | import fs from "fs"; 4 | 5 | export default class Cleanup { 6 | constructor({config}) { 7 | this.database = new Database(config.paths.database); 8 | this.config = config; 9 | } 10 | 11 | run() { 12 | this.database 13 | .prepare('select src, php_path, shim_path from components') 14 | .all() 15 | .forEach(component => { 16 | let src = path.join(this.config.paths.base, component.src); 17 | 18 | // If the source file is gone, then we need to clear out our 19 | // generated files and remove the entry from the database. 20 | if (!fs.existsSync(src)) { 21 | this.delete(component.php_path); 22 | this.delete(component.shim_path); 23 | 24 | this.database.delete(component.src); 25 | } 26 | }); 27 | } 28 | 29 | delete(file) { 30 | if (file) { 31 | try { 32 | fs.unlinkSync(path.join(this.config.paths.base, file)); 33 | } catch (e) { 34 | // 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/vue/Database.js: -------------------------------------------------------------------------------- 1 | import Database from 'better-sqlite3'; 2 | 3 | export default class ComponentDB { 4 | constructor(path) { 5 | this.db = new Database(path); 6 | } 7 | 8 | prepare(query) { 9 | return this.db.prepare(query); 10 | } 11 | 12 | /** 13 | * Ensure all columns exist for the given data object 14 | * @private 15 | * @param {Object} data - Object containing column:value pairs 16 | */ 17 | ensureColumns(data) { 18 | const columns = this.db.prepare(`SELECT name FROM pragma_table_info('components')`).all().map(col => col.name); 19 | 20 | for (const key of Object.keys(data)) { 21 | if (key !== 'src' && !columns.includes(key)) { 22 | throw new Error(`Unknown column [${key}].`); 23 | } 24 | } 25 | } 26 | 27 | /** 28 | * Insert a new component 29 | * @param {string} src - The unique source identifier 30 | * @param {Object} data - Object containing column:value pairs 31 | */ 32 | insert(src, data) { 33 | this.ensureColumns(data); 34 | 35 | const columns = ['src', ...Object.keys(data)]; 36 | const values = ['?', ...Array(Object.keys(data).length).fill('?')]; 37 | 38 | const stmt = this.db.prepare(` 39 | INSERT INTO components (${columns.join(', ')}) 40 | VALUES (${values.join(', ')}) 41 | `); 42 | 43 | stmt.run(src, ...Object.values(data)); 44 | } 45 | 46 | /** 47 | * Update an existing component 48 | * @param {string} src - The unique source identifier 49 | * @param {Object} data - Object containing column:value pairs 50 | */ 51 | set(src, data) { 52 | this.ensureColumns(data); 53 | 54 | const setClause = Object.keys(data) 55 | .map(key => `${key} = ?`) 56 | .join(', '); 57 | 58 | const stmt = this.db.prepare(` 59 | UPDATE components 60 | SET ${setClause} 61 | WHERE src = ? 62 | `); 63 | 64 | const result = stmt.run(...Object.values(data), src); 65 | if (result.changes === 0) { 66 | throw new Error('Component not found'); 67 | } 68 | } 69 | 70 | /** 71 | * Get a component by its source 72 | * @param {string} src - The unique source identifier 73 | * @returns {Object|null} 74 | */ 75 | get(src) { 76 | const stmt = this.db.prepare('SELECT * FROM components WHERE src = ?'); 77 | return stmt.get(src); 78 | } 79 | 80 | upsert(src, data) { 81 | this.ensureColumns(data); 82 | 83 | const columns = ['src', ...Object.keys(data)]; 84 | const values = ['?', ...Array(Object.keys(data).length).fill('?')]; 85 | const updates = Object.keys(data) 86 | .map(key => `${key} = excluded.${key}`) 87 | .join(', '); 88 | 89 | const stmt = this.db.prepare(` 90 | INSERT INTO components (${columns.join(', ')}) 91 | VALUES (${values.join(', ')}) 92 | ON CONFLICT(src) DO UPDATE SET 93 | ${updates} 94 | `); 95 | 96 | stmt.run(src, ...Object.values(data)); 97 | } 98 | 99 | /** 100 | * Delete a component 101 | * @param {string} src - The unique source identifier 102 | */ 103 | delete(src) { 104 | const stmt = this.db.prepare('DELETE FROM components WHERE src = ?'); 105 | const result = stmt.run(src); 106 | if (result.changes === 0) { 107 | throw new Error('Component not found'); 108 | } 109 | } 110 | 111 | /** 112 | * Close the database connection 113 | */ 114 | close() { 115 | this.db.close(); 116 | } 117 | } -------------------------------------------------------------------------------- /packages/vue/Pipeline.js: -------------------------------------------------------------------------------- 1 | import fusionProvidedActions from "./actions/index.js"; 2 | 3 | export default class Pipeline { 4 | constructor(response) { 5 | this.stack = []; 6 | 7 | // This serves as the initial payload that will be passed to 8 | // the very first action. After that each action should 9 | // return the payload to be passed to the next one. 10 | this.initial = { 11 | fusion: response, 12 | state: {} 13 | }; 14 | 15 | response?.actions?.forEach((action) => { 16 | this.use({ 17 | priority: action.priority, 18 | // Append the action itself to the middleware, as it often 19 | // contains params that were added on the server side. 20 | action: {...action}, 21 | 22 | // @TODO user-defined handlers? 23 | handler: fusionProvidedActions[action.handler] ?? function () { 24 | throw new Error(`No handler exported for [${action.handler}].`) 25 | } 26 | }); 27 | }); 28 | } 29 | 30 | use(middleware) { 31 | if (typeof middleware.handler !== 'function' || typeof middleware.priority !== 'number') { 32 | throw new Error(`Invalid middleware: expected an object with a numeric 'priority' and a 'handler' function`); 33 | } 34 | 35 | this.stack.push(middleware); 36 | 37 | return this; 38 | } 39 | 40 | run() { 41 | let index = 0; 42 | 43 | this.stack.sort((a, b) => a.priority - b.priority); 44 | 45 | const execute = (carry) => { 46 | // Possible carryover from the previous iteration. 47 | // Delete it just to be safe. 48 | delete carry.action; 49 | 50 | // No more actions! 51 | if (index >= this.stack.length) { 52 | return carry; 53 | } 54 | 55 | const {handler, action} = this.stack[index++]; 56 | 57 | // Pass context and a `next` function for each middleware to call. 58 | let response = handler({...carry, action, pipeline: this}, execute); 59 | 60 | // An easy way to return a noop or a "pass" is to just return 61 | // the `execute` function (unexecuted!) from the middleware. 62 | if (response === execute) { 63 | return execute(carry); 64 | } 65 | 66 | return response; 67 | }; 68 | 69 | return execute(this.initial); 70 | } 71 | 72 | createState() { 73 | return this.run()?.state || {} 74 | } 75 | } -------------------------------------------------------------------------------- /packages/vue/actions/applyServerState.js: -------------------------------------------------------------------------------- 1 | import {ref, unref} from 'vue'; 2 | 3 | export default function applyServerState ({fusion, state}, next) { 4 | Object.keys(fusion.state).forEach(key => { 5 | state[key] = ref(unref(fusion.state[key])) 6 | }); 7 | 8 | return next({fusion, state}); 9 | } -------------------------------------------------------------------------------- /packages/vue/actions/index.js: -------------------------------------------------------------------------------- 1 | import syncQueryString from "./syncQueryString.js"; 2 | import applyServerState from "./applyServerState.js"; 3 | import log from "./log.js"; 4 | import logStack from "./logStack.js" 5 | 6 | export default { 7 | applyServerState, 8 | syncQueryString, 9 | log, 10 | logStack 11 | } -------------------------------------------------------------------------------- /packages/vue/actions/log.js: -------------------------------------------------------------------------------- 1 | export default function log(ctx, next) { 2 | const {action} = ctx; 3 | 4 | if (action.message) { 5 | console.log(action.message); 6 | } 7 | 8 | console.log(ctx); 9 | 10 | return next; 11 | } -------------------------------------------------------------------------------- /packages/vue/actions/logStack.js: -------------------------------------------------------------------------------- 1 | export default function logStack(ctx, next) { 2 | const {pipeline} = ctx; 3 | 4 | const table = pipeline.stack.map(s => { 5 | return { 6 | priority: s.action.priority, 7 | handler: s.action.handler, 8 | } 9 | }) 10 | console.table(table); 11 | 12 | return next; 13 | } -------------------------------------------------------------------------------- /packages/vue/actions/syncQueryString.js: -------------------------------------------------------------------------------- 1 | import {unref} from 'vue' 2 | 3 | export default function syncQueryString({fusion, state, action}, next) { 4 | const {property, query} = action 5 | 6 | let frameCount = 0 7 | const maxFrames = 60 // About 1 second at 60fps 8 | 9 | function updateUrl() { 10 | const url = new URL(window.location.href) 11 | 12 | if (state.hasOwnProperty(property)) { 13 | const value = unref(state[property]) 14 | 15 | if (!value) { 16 | url.searchParams.delete(query) 17 | } else { 18 | url.searchParams.set(query, value.toString()) 19 | } 20 | } else { 21 | url.searchParams.delete(query) 22 | } 23 | 24 | const newUrl = url.pathname + (url.search || '') 25 | 26 | if (window.location.pathname + window.location.search !== newUrl) { 27 | window.history.replaceState( 28 | window.history.state, 29 | '', 30 | newUrl 31 | ) 32 | } 33 | 34 | frameCount++ 35 | if (frameCount < maxFrames) { 36 | requestAnimationFrame(updateUrl) 37 | } 38 | } 39 | 40 | requestAnimationFrame(updateUrl) 41 | 42 | return next({fusion, state}) 43 | } -------------------------------------------------------------------------------- /packages/vue/hmr.js: -------------------------------------------------------------------------------- 1 | import {unref, isRef} from "@vue/reactivity"; 2 | import Pipeline from "./Pipeline.js"; 3 | 4 | const store = {}; 5 | 6 | export function noop() { 7 | 8 | } 9 | 10 | export default function useHotFusion(fusion, options = {}) { 11 | const {hot, id} = options; 12 | 13 | if (!hot || !id) { 14 | return; 15 | } 16 | 17 | new HMR(id, hot, fusion).init(); 18 | } 19 | 20 | class HMR { 21 | constructor(id, hot, fusion) { 22 | this.id = id; 23 | this.hot = hot; 24 | this.fusion = fusion; 25 | } 26 | 27 | init() { 28 | this.state = store[this.id] ??= { 29 | initialLoad: true, 30 | previousKeys: [], 31 | previousData: {}, 32 | reset() { 33 | delete store[this.id]; 34 | }, 35 | }; 36 | 37 | // Bind methods so we can remove them in removeListeners 38 | this.boundHandleBeforeUpdate = this.handleBeforeUpdate.bind(this); 39 | this.boundHandleAfterUpdate = this.handleAfterUpdate.bind(this); 40 | this.boundHandleBeforeFullReload = this.handleBeforeFullReload.bind(this); 41 | 42 | this.addListeners(); 43 | 44 | if (this.state.initialLoad) { 45 | this.initialLoad(); 46 | } 47 | } 48 | 49 | addListeners() { 50 | this.hot.on('vite:beforeUpdate', this.boundHandleBeforeUpdate); 51 | this.hot.on('vite:afterUpdate', this.boundHandleAfterUpdate); 52 | this.hot.on('vite:beforeFullReload', this.boundHandleBeforeFullReload); 53 | } 54 | 55 | removeListeners() { 56 | this.hot.off('vite:beforeUpdate', this.boundHandleBeforeUpdate); 57 | this.hot.off('vite:afterUpdate', this.boundHandleAfterUpdate); 58 | this.hot.off('vite:beforeFullReload', this.boundHandleBeforeFullReload); 59 | } 60 | 61 | initialLoad() { 62 | this.fetchHotData(); 63 | this.state.initialLoad = false; 64 | } 65 | 66 | handleBeforeFullReload() { 67 | this.removeListeners(); 68 | this.state.reset(); 69 | this.stashExistingData(); 70 | } 71 | 72 | handleBeforeUpdate(payload) { 73 | this.whenUpdated(payload, this.stashExistingData); 74 | } 75 | 76 | handleAfterUpdate(payload) { 77 | this.whenUpdated(payload, this.fetchHotData); 78 | } 79 | 80 | stashExistingData() { 81 | this.state.previousData = Object.keys(this.fusion).reduce((carry, key) => { 82 | carry[key] = unref(this.fusion[key]); 83 | return carry; 84 | }, {}); 85 | } 86 | 87 | fetchHotData() { 88 | this.applyStashedData(); 89 | 90 | axios.post('', {}, { 91 | headers: { 92 | 'X-Fusion-Hmr-Request': 'true', 93 | } 94 | }) 95 | .then((response) => { 96 | const newState = new Pipeline(response.data?.fusion || {}).createState() 97 | const newKeys = Object.keys(newState); 98 | 99 | const keysChanged = this.state.previousKeys.length && !arraysShallowEqual(this.state.previousKeys, newKeys); 100 | 101 | if (keysChanged) { 102 | // They added or removed a prop, so we should just fully reload. 103 | this.removeListeners(); 104 | window.location.reload(); 105 | } else { 106 | this.state.previousKeys = newKeys; 107 | this.applyData(newState); 108 | } 109 | }) 110 | .catch(() => { 111 | console.warn(`[HMR:${this.id}] Hot data fetch failed.`); 112 | }); 113 | } 114 | 115 | applyStashedData() { 116 | this.applyData(this.state.previousData); 117 | } 118 | 119 | applyData(data = {}) { 120 | Object.keys(data).forEach((key) => { 121 | if (key in this.fusion && isRef(this.fusion[key])) { 122 | // Coming out of the pipeline it'll be a ref. Coming from previously 123 | // stashed data it'll be a raw value. `unref` handles both. 124 | this.fusion[key].value = unref(data[key]); 125 | } 126 | }); 127 | } 128 | 129 | whenUpdated(payload, callback) { 130 | // Make sure this HMR update applies to this component. 131 | if (payload.updates.some(u => u.path === this.hot.ownerPath)) { 132 | callback.call(this); 133 | } 134 | } 135 | } 136 | 137 | function arraysShallowEqual(arr1, arr2) { 138 | if (arr1.length !== arr2.length) return false; 139 | for (let i = 0; i < arr1.length; i++) { 140 | if (arr1[i] !== arr2[i]) return false; 141 | } 142 | return true; 143 | } -------------------------------------------------------------------------------- /packages/vue/injectors/createScriptSetup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This one is super easy, all we have to do is create a new 3 | * script setup block and put it below the user's code. 4 | * 5 | * @param code 6 | * @returns {string} 7 | */ 8 | export default function injector(code) { 9 | return ` 10 | ${code} 11 | 22 | ` 23 | } -------------------------------------------------------------------------------- /packages/vue/injectors/index.js: -------------------------------------------------------------------------------- 1 | import optionsWithSetup from './optionsWithSetup.js' 2 | import optionsWithoutSetup from './optionsWithoutSetup.js' 3 | import createScriptSetup from './createScriptSetup.js' 4 | import modifyScriptSetup from './modifyScriptSetup.js' 5 | 6 | export default { 7 | optionsWithSetup, 8 | optionsWithoutSetup, 9 | createScriptSetup, 10 | modifyScriptSetup, 11 | } -------------------------------------------------------------------------------- /packages/vue/injectors/modifyScriptSetup.js: -------------------------------------------------------------------------------- 1 | import replaceBlock from "../lib/replaceBlock.js"; 2 | import {FusionHelpers} from "./utils.js"; 3 | 4 | export default function modifyScriptSetup(code, script, keys) { 5 | const {code: rewritten, remainingKeys} = FusionHelpers.processScriptSetup( 6 | script.content, 7 | keys, 8 | "__props.fusion" 9 | ); 10 | return [replaceBlock(code, script, rewritten), remainingKeys]; 11 | } -------------------------------------------------------------------------------- /packages/vue/injectors/optionsWithSetup.js: -------------------------------------------------------------------------------- 1 | import replaceBlock from "../lib/replaceBlock.js"; 2 | import {FusionHelpers} from "./utils.js"; 3 | 4 | export default function optionsWithSetup(code, script, keys) { 5 | const {code: rewritten, remainingKeys} = FusionHelpers.processOptionsSetup( 6 | script.content, 7 | keys, 8 | "(typeof props !== 'undefined' && props.fusion) || (typeof arguments !== 'undefined' && arguments.length > 0 && arguments[0]?.fusion) || {}" 9 | ); 10 | return [replaceBlock(code, script, rewritten), remainingKeys]; 11 | } -------------------------------------------------------------------------------- /packages/vue/injectors/optionsWithoutSetup.js: -------------------------------------------------------------------------------- 1 | import {rewriteDefault} from "@vue/compiler-sfc"; 2 | import replaceBlock from "../lib/replaceBlock.js"; 3 | 4 | export default function injector(code, script, keys) { 5 | const rewritten = rewriteDefault(script.content, '__default__') + ` 6 | import {useFusion as __useFusion} from "__aliasedFusionPath__"; 7 | 8 | __default__.setup = function(props) { 9 | return __useFusion([__exportedKeysAsQuotedCsv__], props.fusion); 10 | }; 11 | 12 | export default __default__; 13 | ` 14 | 15 | // Swap out the original script block with our new one. 16 | return [replaceBlock(code, script, rewritten), keys]; 17 | } 18 | -------------------------------------------------------------------------------- /packages/vue/injectors/utils.js: -------------------------------------------------------------------------------- 1 | // fusionHelpers.js 2 | import {rewriteDefault} from "@vue/compiler-sfc"; 3 | 4 | const checkAliasedImport = (code) => { 5 | const regex = /import\s*{([^}]+)}/gm; 6 | let match; 7 | while ((match = regex.exec(code)) !== null) { 8 | const imports = match[1].split(","); 9 | for (const imp of imports) { 10 | if (/^useFusion\s+as\s+\w+$/i.test(imp.trim())) { 11 | throw new Error("Aliased useFusion import is not allowed."); 12 | } 13 | } 14 | } 15 | }; 16 | 17 | const removeUseFusionImport = (code) => { 18 | const regex = /^.*import\s*{[^}]*useFusion[^}]*}.*$/gm; 19 | return code.replace(regex, ""); 20 | }; 21 | 22 | const extractImports = (code) => { 23 | const importRegex = /^.*import.*$/gm; 24 | const imports = []; 25 | let remainingCode = code.replace(importRegex, (match) => { 26 | imports.push(match); 27 | return ''; 28 | }); 29 | return {imports, remainingCode: remainingCode.trim()}; 30 | }; 31 | 32 | const extractUseFusionKeys = (code) => { 33 | const regex = /useFusion\s*\(\s*\[([\s\S]*?)\]\s*\)/g; 34 | let handledKeys = []; 35 | let match; 36 | let isMultiline = false; 37 | 38 | // Check for parameterless call first 39 | if (/useFusion\s*\(\s*\)/.test(code)) { 40 | return {handledKeys: ["*"], isParameterless: true, isMultiline: false}; 41 | } 42 | 43 | while ((match = regex.exec(code)) !== null) { 44 | if (!match[1]) continue; 45 | if (match[1].includes('\n')) { 46 | isMultiline = true; 47 | } 48 | const keysInCall = match[1] 49 | .replace(/\n/g, " ") 50 | .split(",") 51 | .map((k) => k.trim().replace(/['"]/g, "")) 52 | .filter(Boolean); 53 | handledKeys.push(...keysInCall); 54 | } 55 | 56 | return { 57 | handledKeys, 58 | isParameterless: false, 59 | isMultiline 60 | }; 61 | }; 62 | 63 | const validateSingleUseFusion = (code) => { 64 | const callCount = (code.match(/useFusion\s*\(/g) || []).length; 65 | if (callCount > 1) { 66 | throw new Error("Multiple useFusion calls are not allowed."); 67 | } 68 | return callCount > 0; 69 | }; 70 | 71 | const rewriteUseFusionCall = (code, fusionSource) => { 72 | const regex = /(useFusion\s*\()(\s*\[[\s\S]*?\])?\s*\)/g; 73 | return code.replace(regex, (match, prefix, params) => { 74 | if (!params) { 75 | return `${prefix}[__exportedKeysAsQuotedCsv__], ${fusionSource}, true)`; 76 | } 77 | return `${prefix}${params}, ${fusionSource}, true)`; 78 | }); 79 | }; 80 | 81 | const calculateRemainingKeys = (allKeys, handledKeys) => { 82 | if (handledKeys.includes("*")) return []; 83 | return allKeys.filter(key => !handledKeys.includes(key)); 84 | }; 85 | 86 | const processScriptSetup = (code, keys, fusionSource = "__props.fusion") => { 87 | checkAliasedImport(code); 88 | const cleanCode = removeUseFusionImport(code); 89 | 90 | // Extract all imports first 91 | const {imports, remainingCode} = extractImports(cleanCode); 92 | 93 | const hasCall = validateSingleUseFusion(remainingCode); 94 | const {handledKeys, isParameterless, isMultiline} = extractUseFusionKeys(remainingCode); 95 | const remainingKeys = calculateRemainingKeys(keys, handledKeys); 96 | 97 | let processedCode = remainingCode; 98 | if (hasCall) { 99 | processedCode = rewriteUseFusionCall(remainingCode, fusionSource); 100 | } 101 | 102 | // Only inject extra useFusion call if we have remaining keys and it's not a multiline call 103 | const shouldInjectExtra = remainingKeys.length > 0 && !isMultiline; 104 | const injectedUseFusion = shouldInjectExtra ? 105 | `const {__exportedKeysAsCsv__} = useFusion([__exportedKeysAsQuotedCsv__], ${fusionSource});\n` : 106 | ''; 107 | 108 | // Combine in order: our import, original imports, injected call, processed code 109 | const allImports = [ 110 | 'import { useFusion } from "__aliasedFusionPath__";', 111 | ...imports 112 | ].join('\n'); 113 | 114 | return { 115 | code: `${allImports}\n${injectedUseFusion}${processedCode}`, 116 | remainingKeys: isParameterless ? [] : remainingKeys 117 | }; 118 | }; 119 | 120 | const processOptionsSetup = (code, keys) => { 121 | checkAliasedImport(code); 122 | const cleanCode = removeUseFusionImport(code); 123 | const hasCall = validateSingleUseFusion(cleanCode); 124 | 125 | const {handledKeys, isParameterless} = extractUseFusionKeys(cleanCode); 126 | let remainingKeys = calculateRemainingKeys(keys, handledKeys); 127 | 128 | const rewrittenCode = hasCall ? 129 | rewriteUseFusionCall(cleanCode, "__fusionProvidedProps.fusion || {}") : 130 | cleanCode; 131 | remainingKeys = isParameterless ? [] : remainingKeys; 132 | 133 | const withDefaultRewritten = rewriteDefault(rewrittenCode, "__default__"); 134 | 135 | const wrapped = `let __fusionProvidedProps; 136 | 137 | ${withDefaultRewritten} 138 | 139 | import { useFusion } from "__aliasedFusionPath__"; 140 | 141 | const userSetup = __default__.setup; 142 | 143 | __default__.setup = function(props, ctx) { 144 | __fusionProvidedProps = props; 145 | const fusionData = ${remainingKeys.length > 0 ? 146 | `useFusion([__exportedKeysAsQuotedCsv__], props.fusion || {})` : 147 | '{}'}; 148 | let userReturns = typeof userSetup === 'function' ? userSetup(props, ctx) : {}; 149 | return { ...fusionData, ...userReturns }; 150 | }; 151 | 152 | export default __default__;`; 153 | 154 | return {code: wrapped, remainingKeys}; 155 | }; 156 | 157 | export const FusionHelpers = { 158 | processScriptSetup, 159 | processOptionsSetup 160 | }; -------------------------------------------------------------------------------- /packages/vue/lib/replaceBlock.js: -------------------------------------------------------------------------------- 1 | export default function replaceBlock(code, block, replacement = '') { 2 | if (!block) { 3 | return code; 4 | } 5 | 6 | return code.slice(0, block.loc.start.offset) + replacement + code.slice(block.loc.end.offset) 7 | } 8 | -------------------------------------------------------------------------------- /packages/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Aaron Francis (http://aaronfrancis.com/)", 3 | "name": "@fusion/vue", 4 | "version": "1.0.0", 5 | "description": "", 6 | "type": "module", 7 | "exports": { 8 | "./vite": "./vite.js", 9 | "./vue": "./vue.js", 10 | "./hmr": "./hmr.js", 11 | "./actions": "./actions/index.js", 12 | "./pipeline": "./Pipeline.js", 13 | "./actionFactory": "./ActionFactory.js", 14 | "./testExtractor": "./testExtractor.js" 15 | }, 16 | "scripts": { 17 | "fusion:install": "node scripts/install.js", 18 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" 19 | }, 20 | "private": true, 21 | "peerDependencies": { 22 | "@inertiajs/vue3": "^2.0.0", 23 | "vue": "^3.0.0" 24 | }, 25 | "devDependencies": { 26 | "@vue/compiler-sfc": "^3.5.13", 27 | "better-sqlite3": "^11.8.1", 28 | "jest": "^29.7.0", 29 | "magic-string": "^0.30.17", 30 | "picocolors": "^1.1.1", 31 | "vite": "^6.0.7" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/vue/scripts/install.js: -------------------------------------------------------------------------------- 1 | import {execSync} from 'child_process'; 2 | 3 | try { 4 | console.log('\x1b[36m%s\x1b[0m', '📦 Installing Fusion dependencies...'); 5 | 6 | execSync('npm install', { 7 | stdio: 'inherit' // Show output in real time 8 | }); 9 | 10 | console.log('\x1b[32m%s\x1b[0m', '✅ Fusion dependencies installed successfully'); 11 | } catch (error) { 12 | console.error('\x1b[31m%s\x1b[0m', '❌ Error installing Fusion dependencies:'); 13 | console.error('\x1b[31m%s\x1b[0m', error.message); 14 | process.exit(1); 15 | } -------------------------------------------------------------------------------- /packages/vue/testExtractor.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | class TestExtractor { 5 | constructor(outputDir) { 6 | this.outputDir = outputDir; 7 | this.fullOutputDir = path.join(process.cwd(), outputDir); 8 | } 9 | 10 | clearOutputDirectory() { 11 | if (fs.existsSync(this.fullOutputDir)) { 12 | fs.rmSync(this.fullOutputDir, {recursive: true, force: true}); 13 | } 14 | } 15 | 16 | isVueFile(id) { 17 | return id.endsWith('.vue') || id.includes('.vue?'); 18 | } 19 | 20 | processFile(code, id) { 21 | const filename = id.split('?')[0]; 22 | 23 | // Match either -------------------------------------------------------------------------------- /src/Casting/CasterInterface.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Casting; 8 | 9 | use ReflectionNamedType; 10 | 11 | interface CasterInterface 12 | { 13 | /** 14 | * Determines if this caster supports casting for the given Reflection type. 15 | */ 16 | public function supportsType(ReflectionNamedType $type): bool; 17 | 18 | /** 19 | * Converts the given PHP value into a transportable structure (array or primitive). 20 | */ 21 | public function toTransport(ReflectionNamedType $type, mixed $value): Transportable; 22 | 23 | /** 24 | * Converts a transportable structure back into a PHP value. 25 | * (You may throw an exception if the structure is malformed or incompatible.) 26 | */ 27 | public function fromTransport(array $transportable): mixed; 28 | } 29 | -------------------------------------------------------------------------------- /src/Casting/CasterRegistry.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Casting; 8 | 9 | use Fusion\Casting\Casters\BuiltinScalarCaster; 10 | use Fusion\Casting\Casters\DateTimeCaster; 11 | use InvalidArgumentException; 12 | use ReflectionNamedType; 13 | 14 | class CasterRegistry 15 | { 16 | /** 17 | * @var CasterInterface[] 18 | */ 19 | private static array $casters = [ 20 | BuiltinScalarCaster::class, 21 | DateTimeCaster::class, 22 | ]; 23 | 24 | public static function registerCaster(string $caster): void 25 | { 26 | if (!is_a($caster, CasterInterface::class, true)) { 27 | throw new InvalidArgumentException("Caster [{$caster}] does not implement CasterInterface."); 28 | } 29 | 30 | self::$casters[] = $caster; 31 | } 32 | 33 | /** 34 | * Return the first caster that supports the given ReflectionNamedType. 35 | */ 36 | public static function getCasterForType(ReflectionNamedType $type): ?CasterInterface 37 | { 38 | foreach (self::$casters as $caster) { 39 | $caster = app($caster); 40 | 41 | if ($caster->supportsType($type)) { 42 | return $caster; 43 | } 44 | } 45 | 46 | return null; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Casting/Casters/BuiltinScalarCaster.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Casting\Casters; 8 | 9 | use Fusion\Casting\CasterInterface; 10 | use Fusion\Casting\Transportable; 11 | use Illuminate\Support\Arr; 12 | use ReflectionNamedType; 13 | 14 | class BuiltinScalarCaster implements CasterInterface 15 | { 16 | public function supportsType(ReflectionNamedType $type): bool 17 | { 18 | return in_array($type->getName(), [ 19 | 'bool', 20 | 'float', 21 | 'int', 22 | 'null', 23 | 'string', 24 | 'false', 25 | 'true', 26 | ]); 27 | 28 | // @TODO 29 | // 'array', 'callable', 'object', 'iterable', 'never', 'void', 'mixed' 30 | } 31 | 32 | public function toTransport(ReflectionNamedType $type, mixed $value): Transportable 33 | { 34 | return Transportable::make($value)->withMeta([ 35 | 'type' => $type->getName(), 36 | ]); 37 | } 38 | 39 | public function fromTransport(array $transportable): mixed 40 | { 41 | $value = Arr::get($transportable, 'value'); 42 | $type = Arr::get($transportable, 'meta.type'); 43 | 44 | if (is_null($value)) { 45 | return null; 46 | } 47 | 48 | return match ($type) { 49 | 'bool' => (bool) $value, 50 | 'float' => (float) $value, 51 | 'int' => (int) $value, 52 | 'string' => (string) $value, 53 | 'false' => false, 54 | 'true' => true, 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Casting/Casters/DateTimeCaster.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Casting\Casters; 8 | 9 | use DateTime; 10 | use DateTimeInterface; 11 | use Fusion\Casting\CasterInterface; 12 | use Fusion\Casting\Transportable; 13 | use Fusion\Reflection\ReflectionClass; 14 | use ReflectionNamedType; 15 | 16 | class DateTimeCaster implements CasterInterface 17 | { 18 | public function supportsType(ReflectionNamedType $type): bool 19 | { 20 | return is_a($type->getName(), DateTimeInterface::class, true); 21 | } 22 | 23 | public function toTransport(ReflectionNamedType $type, mixed $value): Transportable 24 | { 25 | /** @var DateTimeInterface $value */ 26 | return Transportable::make($value?->getTimestamp())->withMeta([ 27 | 'class' => is_null($value) ? $type->getName() : get_class($value), 28 | ]); 29 | } 30 | 31 | public function fromTransport(array $transportable): mixed 32 | { 33 | // Create a reinstantiableAs instance from the integer timestamp 34 | } 35 | 36 | protected function reinstantiableAs($class): mixed 37 | { 38 | if (!class_exists($class)) { 39 | return DateTime::class; 40 | } 41 | 42 | if ((new ReflectionClass($class))->isInstantiable()) { 43 | return $class; 44 | } 45 | 46 | // At this point, the class is either an abstract or an interface. 47 | // We'll see if there's a instantiable class bound in the 48 | // container, otherwise we're out of options. 49 | if (app()->bound($class)) { 50 | return get_class(app()->make($class)); 51 | } 52 | 53 | return DateTime::class; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Casting/JavaScriptVariable.php: -------------------------------------------------------------------------------- 1 | withValue($value); 29 | } 30 | 31 | public function __construct(ReflectionParameter|ReflectionProperty $reflection) 32 | { 33 | $this->reflection = $reflection; 34 | } 35 | 36 | public function withValue(mixed $value): JavaScriptVariable 37 | { 38 | $this->valueSet = true; 39 | $this->value = $value; 40 | 41 | return $this; 42 | } 43 | 44 | public function getCasterForCasting(): ?CasterInterface 45 | { 46 | // Attempt to find a matching caster based on the reflection type 47 | $type = $this->reflection->getType(); 48 | 49 | return CasterRegistry::getCasterForType($type); 50 | 51 | // if ($type instanceof ReflectionUnionType) { 52 | // // If it's a union type, try each named type until we find a caster 53 | // foreach ($type->getTypes() as $namedType) { 54 | // $tempCaster = CasterRegistry::getCasterForType($namedType); 55 | // if ($tempCaster !== null) { 56 | // $this->caster = $tempCaster; 57 | // break; 58 | // } 59 | // } 60 | // } elseif ($type instanceof ReflectionNamedType) { 61 | // // Single named type 62 | // $this->caster = CasterRegistry::getCasterForType($type); 63 | // } 64 | } 65 | 66 | /** 67 | * Convert the stored value into a transportable structure for JS. 68 | */ 69 | public function toTransportable(): Transportable 70 | { 71 | if (!$this->valueSet) { 72 | throw new Exception('Cannot create a transportable JavaScript variable without a value.'); 73 | } 74 | 75 | if (!$caster = $this->getCasterForCasting()) { 76 | return Transportable::unknown($this->value); 77 | } 78 | 79 | return $caster 80 | ->toTransport($this->reflection->getType(), $this->value) 81 | ->withMeta([ 82 | 'caster' => get_class($caster), 83 | ]); 84 | } 85 | 86 | /** 87 | * Helper for reconstructing a PHP value from an incoming transportable structure. 88 | * Example usage: $phpValue = JavaScriptVariable::fromTransportable($reflection, $_POST['myVar']); 89 | */ 90 | public static function fromTransportable( 91 | ReflectionParameter|ReflectionProperty $reflection, 92 | array $transportable 93 | ): mixed { 94 | $reflectionType = $reflection->getType(); 95 | 96 | if ($reflectionType instanceof ReflectionUnionType) { 97 | foreach ($reflectionType->getTypes() as $namedType) { 98 | $caster = CasterRegistry::getCasterForType($namedType); 99 | if ($caster !== null) { 100 | return $caster->fromTransport($transportable); 101 | } 102 | } 103 | 104 | // Fallback if no caster matches 105 | return $transportable['value'] ?? null; 106 | 107 | } elseif ($reflectionType instanceof ReflectionNamedType) { 108 | $caster = CasterRegistry::getCasterForType($reflectionType); 109 | if ($caster) { 110 | return $caster->fromTransport($transportable); 111 | } 112 | } 113 | 114 | // If no caster was found, just return the raw 'value' 115 | return $transportable['value'] ?? null; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Casting/Transportable.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Casting; 8 | 9 | use Illuminate\Contracts\Support\Arrayable; 10 | use JsonSerializable; 11 | 12 | class Transportable implements Arrayable, JsonSerializable 13 | { 14 | protected mixed $value; 15 | 16 | protected array $meta = []; 17 | 18 | public static function unknown(mixed $value): static 19 | { 20 | $transportable = new static($value); 21 | $transportable->meta = [ 22 | 'type' => 'raw' 23 | ]; 24 | 25 | return $transportable; 26 | } 27 | 28 | public static function make(mixed $value): static 29 | { 30 | return new static($value); 31 | } 32 | 33 | public function __construct(mixed $value) 34 | { 35 | $this->value = $value; 36 | } 37 | 38 | public function withMeta($meta): static 39 | { 40 | $this->meta = [ 41 | ...$this->meta, 42 | ...$meta 43 | ]; 44 | 45 | return $this; 46 | } 47 | 48 | public function toArray(): array 49 | { 50 | return [ 51 | 'value' => $this->value, 52 | 'meta' => empty($this->meta) ? [] : [ 53 | // Magic key that we look for on the frontend. to tell if 54 | // this object needs to be unwrapped into a real value. 55 | 'isFusion' => true, 56 | ...$this->meta, 57 | 58 | // Since functions rely on the metadata being there and 59 | // being correct, we need to sign it. Especially if 60 | // we (or the end developer) includes any FQCNs. 61 | 'checksum' => $this->signature(), 62 | ], 63 | ]; 64 | } 65 | 66 | protected function signature(): string 67 | { 68 | $sorted = $this->meta; 69 | ksort($sorted); 70 | 71 | return hash('sha256', json_encode($sorted) . config('app.key')); 72 | } 73 | 74 | public function jsonSerialize(): string 75 | { 76 | return json_encode($this->toArray(), JSON_THROW_ON_ERROR); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Concerns/BootsTraits.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Concerns; 8 | 9 | trait BootsTraits 10 | { 11 | public function bootTraits(): void 12 | { 13 | $class = static::class; 14 | 15 | $booted = []; 16 | 17 | foreach (class_uses_recursive($class) as $trait) { 18 | $method = 'boot' . class_basename($trait); 19 | 20 | if (method_exists($class, $method) && !in_array($method, $booted)) { 21 | call_user_func([$this, $method]); 22 | 23 | $booted[] = $method; 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Concerns/ComputesState.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Concerns; 8 | 9 | use App\Http\Actions\Computed; 10 | use Fusion\Casting\JavaScriptVariable; 11 | use Fusion\FusionPage; 12 | use Illuminate\Support\Collection; 13 | use Illuminate\Support\Str; 14 | use ReflectionMethod; 15 | use ReflectionProperty; 16 | 17 | /** 18 | * @mixin FusionPage 19 | */ 20 | trait ComputesState 21 | { 22 | public function state(): array 23 | { 24 | // @TODO casting, cleaning up prop names 25 | 26 | return collect() 27 | ->merge($this->getStateFromPublicProperties()) 28 | ->merge($this->getStateFromComputedMethods()) 29 | ->when($this->isProcedural(), function ($collection) { 30 | return $collection->merge($this->props); 31 | }) 32 | ->mapWithKeys(fn($value, $key) => [ 33 | $this->formatStateKey($key) => $value 34 | ]) 35 | ->all(); 36 | } 37 | 38 | protected function getStateFromComputedMethods(): Collection 39 | { 40 | return $this->reflector->computedPropertyMethods() 41 | ->mapWithKeys(function (ReflectionMethod $method) { 42 | return [ 43 | $method->getName() => $method->invoke($this) 44 | ]; 45 | }) 46 | ->toBase(); 47 | 48 | } 49 | 50 | protected function getStateFromPublicProperties() 51 | { 52 | return $this->reflector->propertiesForState() 53 | ->keyBy(fn(ReflectionProperty $p) => $p->getName()) 54 | ->map(function (ReflectionProperty $property) { 55 | $value = $this->getValueFromProperty($property); 56 | 57 | return $value; 58 | 59 | return JavaScriptVariable::makeWithValue($property, $value)->toTransportable(); 60 | }) 61 | ->toArray(); 62 | } 63 | 64 | protected function formatStateKey(string $key): string 65 | { 66 | // Computed props from methods 67 | $key = Str::match('/^get(.*)Prop$/', $key) ?: $key; 68 | 69 | return Str::camel(lcfirst($key)); 70 | } 71 | 72 | protected function getValueFromProperty(ReflectionProperty $property) 73 | { 74 | return $property->isInitialized($this) ? $this->{$property->getName()} : null; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Concerns/HandlesExposedActions.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Concerns; 8 | 9 | use Fusion\FusionPage; 10 | use Fusion\Reflection\ReflectionCollection; 11 | use Fusion\Reflection\Reflector; 12 | use Illuminate\Contracts\Routing\UrlRoutable; 13 | use Illuminate\Database\Eloquent\Model; 14 | use Illuminate\Support\Str; 15 | use ReflectionMethod; 16 | use ReflectionParameter; 17 | 18 | /** 19 | * @mixin FusionPage 20 | */ 21 | trait HandlesExposedActions 22 | { 23 | public function actions(): array 24 | { 25 | return $this->reflector->exposedActionMethods() 26 | ->keyBy(fn(ReflectionMethod $m) => $m->getName()) 27 | ->map(function (ReflectionMethod $method, $name) { 28 | return [ 29 | 'name' => $name, 30 | 'integrity' => $this->computeActionIntegrity($method), 31 | 'signature' => $this->calculateJavaScriptSignature($method), 32 | ]; 33 | }) 34 | ->all(); 35 | } 36 | 37 | protected function calculateJavaScriptSignature(ReflectionMethod $method) 38 | { 39 | return ReflectionCollection::make($method->getParameters()) 40 | ->map(function (ReflectionParameter $parameter) { 41 | if (Reflector::isParameterSubclassOf($parameter, UrlRoutable::class)) { 42 | $type = $this->getTypeFromUrlRoutableParameter($parameter); 43 | } elseif (Reflector::isParameterBackedEnumWithStringBackingType($parameter)) { 44 | $type = 'string'; 45 | } else { 46 | $type = 'mixed'; 47 | } 48 | 49 | return [ 50 | 'name' => $parameter->getName(), 51 | 'type' => $type, 52 | 'nullable' => $parameter->allowsNull(), 53 | ]; 54 | }); 55 | } 56 | 57 | protected function getTypeFromUrlRoutableParameter(ReflectionParameter $parameter): string 58 | { 59 | // @TODO 60 | // $route->allowsTrashedBindings() 61 | // $route->preventsScopedBindings() 62 | // $route->enforcesScopedBindings() 63 | 64 | /** @var UrlRoutable $instance */ 65 | $instance = app()->make(Reflector::getParameterClassName($parameter)); 66 | 67 | if (is_a($instance, Model::class)) { 68 | return $instance->getKeyType(); 69 | } 70 | 71 | return 'mixed'; 72 | } 73 | 74 | protected function computeActionIntegrity(ReflectionMethod $method): string 75 | { 76 | // We don't care about this in prod. This is just for knowing when a function 77 | // changed so much that we need to fully reload the page in development. 78 | if (app()->environment('production')) { 79 | return ''; 80 | } 81 | 82 | // This is a description of where the body of the function is and how many 83 | // lines it is. We don't care if the body changes, just the signature. 84 | $exported = Str::replaceMatches('/^\s*@@.*$/m', '', (string) $method); 85 | 86 | return 'func_' . md5($exported); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Concerns/InteractsWithQueryStrings.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Concerns; 8 | 9 | use Exception; 10 | use Fusion\Attributes; 11 | use Fusion\Fusion; 12 | use Fusion\FusionPage; 13 | use Fusion\Http\Response\Actions; 14 | use Illuminate\Http\Request; 15 | use ReflectionProperty; 16 | 17 | /** 18 | * @mixin FusionPage 19 | */ 20 | trait InteractsWithQueryStrings 21 | { 22 | public function initializeFromQueryString(Request $request): void 23 | { 24 | $this->reflector->propertiesInitFromQueryString() 25 | ->each(function (ReflectionProperty $property) { 26 | $name = $property->getName(); 27 | 28 | $attr = $property->getAttributes(Attributes\SyncQueryString::class); 29 | $attr = head($attr)->newInstance(); 30 | 31 | // It's possible to name the query string one thing ($attr->as) 32 | // and the property something else ($property->getName().) 33 | $q = $attr->as ?? $name; 34 | 35 | $this->addQueryStringSyncAction($name, $q); 36 | 37 | if (!$this->hasQueryStringValue($name, $q)) { 38 | return; 39 | } 40 | 41 | $value = $this->valueFromQueryString($name, $q); 42 | 43 | $this->{$name} = $value; 44 | }); 45 | } 46 | 47 | protected function addQueryStringSyncAction($name, $q): void 48 | { 49 | Fusion::response()->addAction( 50 | new Actions\SyncQueryString($name, $q) 51 | ); 52 | } 53 | 54 | protected function hasQueryStringValue($name, $q): mixed 55 | { 56 | return request()->isFusionAction() && request()->json()->has($name) || request()->query->has($q); 57 | } 58 | 59 | protected function valueFromQueryString($name, $q): mixed 60 | { 61 | // In a scenario where the querystring is "?search=foo" and a Fusion action 62 | // comes in with some data that says {search: bar} we need to defer to the 63 | // JSON data, otherwise the search property from the action will be inert. 64 | if (Fusion::request()->isFusionAction() && Fusion::request()->state->has($name)) { 65 | return Fusion::request()->state->get($name); 66 | } elseif (request()->query->has($q)) { 67 | return Fusion::request()->base->query($q); 68 | } 69 | 70 | throw new Exception('No value available. Guard with `hasQueryStringValue` before calling.'); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Concerns/IsProceduralPage.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Concerns; 8 | 9 | use Closure; 10 | use Fusion\Fusion; 11 | use Fusion\FusionPage; 12 | use Fusion\Reflection\ReflectionCollection; 13 | use Fusion\Routing\SubstituteBindings; 14 | use Fusion\Support\PendingProp; 15 | use Laravel\SerializableClosure\Support\ReflectionClosure; 16 | use ReflectionParameter; 17 | 18 | /** 19 | * @mixin FusionPage 20 | */ 21 | trait IsProceduralPage 22 | { 23 | protected bool $isProcedural = true; 24 | 25 | protected array $props = [ 26 | // 27 | ]; 28 | 29 | protected array $actions = [ 30 | // 31 | ]; 32 | 33 | // This is the user's code from the .vue file. 34 | abstract public function runProceduralCode(); 35 | 36 | protected function syncProps($definedVariables): void 37 | { 38 | foreach ($this->props as $name => $value) { 39 | $this->props[$name] = $definedVariables[$name] ?? null; 40 | } 41 | } 42 | 43 | protected function resolveProvidedValue(PendingProp $prop) 44 | { 45 | $this->props[$prop->name] = null; 46 | 47 | if ($prop->fromRoute) { 48 | return $this->getPropValueFromRoute($prop); 49 | } 50 | 51 | if ($prop->queryStringName) { 52 | $this->addQueryStringSyncAction($prop->name, $prop->queryStringName); 53 | 54 | if ($this->hasQueryStringValue($prop->name, $prop->queryStringName)) { 55 | return $this->valueFromQueryString($prop->name, $prop->queryStringName); 56 | } 57 | } 58 | 59 | if ($prop->readonly) { 60 | return value($prop->default); 61 | } 62 | 63 | // State is a Fluent, so `get` calls `value` on default. 64 | return Fusion::request()->state->get($prop->name, $prop->default); 65 | } 66 | 67 | protected function prop($default = null, string $name = ''): PendingProp 68 | { 69 | return (new PendingProp($name, $default)) 70 | ->setValueResolver($this->resolveProvidedValue(...)); 71 | } 72 | 73 | protected function expose(...$args): void 74 | { 75 | foreach ($args as $name => $handler) { 76 | $this->actions[$name] = $handler; 77 | } 78 | } 79 | 80 | protected function mount(Closure $callback) 81 | { 82 | $reflection = new ReflectionClosure($callback); 83 | 84 | // Check their mount signature for arguments we can use. 85 | $bindings = ReflectionCollection::make($reflection->getParameters()) 86 | ->keyBy('name') 87 | ->map(function (ReflectionParameter $item) { 88 | $type = $item->getType()?->getName(); 89 | 90 | return [ 91 | 'class' => class_exists($type) ? $type : null, 92 | ]; 93 | }) 94 | ->toArray(); 95 | 96 | $parameters = Fusion::request()->base->route()->parameters(); 97 | 98 | $resolved = (new SubstituteBindings($bindings))->resolve($parameters); 99 | 100 | return app()->call($callback, $resolved); 101 | } 102 | 103 | protected function getPropValueFromRoute(PendingProp $prop) 104 | { 105 | $parameters = Fusion::request()->base->route()->parameters(); 106 | 107 | $binding = is_null($prop->binding) ? [] : [ 108 | 'class' => $prop->binding->to, 109 | 'key' => $prop->binding->using, 110 | 'withTrashed' => $prop->binding->withTrashed, 111 | ]; 112 | 113 | $binder = new SubstituteBindings([ 114 | $prop->fromRoute => $binding, 115 | ]); 116 | 117 | return $binder->resolve($parameters)[$prop->fromRoute]; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Conformity/Conformer.php: -------------------------------------------------------------------------------- 1 | content, 'content = "content; 31 | } 32 | 33 | $this->rehabMissingSemicolon($this->content); 34 | 35 | $this->transformers = $transformers ?? $this->defaultTransformers(); 36 | } 37 | 38 | protected function rehabMissingSemicolon(string $content): void 39 | { 40 | try { 41 | (new ParserFactory)->createForHostVersion()->parse($this->content); 42 | } catch (Error $e) { 43 | if (Str::startsWith($e->getMessage(), 'Syntax error, unexpected EOF')) { 44 | $pos = strrpos($this->content, '}'); 45 | 46 | if ($pos !== false) { 47 | $this->content = substr_replace($this->content, ';', $pos + 1, 0); 48 | } 49 | } 50 | } 51 | } 52 | 53 | public function getFullyQualifiedName(): ?string 54 | { 55 | return $this->fqcn; 56 | } 57 | 58 | public function setFilename(string $filename): static 59 | { 60 | $this->filename = $filename; 61 | 62 | return $this; 63 | } 64 | 65 | protected function defaultTransformers(): array 66 | { 67 | return [ 68 | Transformers\ProceduralTransformer::class, 69 | Transformers\PropTransformer::class, 70 | Transformers\FunctionImportTransformer::class, 71 | Transformers\PropValueTransformer::class, 72 | Transformers\ExposeTransformer::class, 73 | Transformers\MountTransformer::class, 74 | Transformers\PropDiscoveryTransformer::class, 75 | Transformers\ActionDiscoveryTransformer::class, 76 | Transformers\AnonymousReturnTransformer::class, 77 | Transformers\AnonymousClassTransformer::class, 78 | ]; 79 | } 80 | 81 | protected function setFullyQualifiedName(array $ast): void 82 | { 83 | $namespace = ''; 84 | $className = ''; 85 | 86 | foreach ($ast as $node) { 87 | if ($node instanceof Namespace_) { 88 | $namespace = $node->name->toString(); 89 | 90 | foreach ($node->stmts as $stmt) { 91 | if ($stmt instanceof Class_) { 92 | $className = $stmt->name->toString(); 93 | break; 94 | } 95 | } 96 | } elseif ($node instanceof Class_) { 97 | $className = $node->name->toString(); 98 | } 99 | } 100 | 101 | if (empty($className)) { 102 | return; 103 | } 104 | 105 | $this->fqcn = $namespace ? "\\{$namespace}\\{$className}" : $className; 106 | } 107 | 108 | public function conform(): string 109 | { 110 | $parser = (new ParserFactory)->createForHostVersion(); 111 | 112 | $ast = $parser->parse($this->content); 113 | $ast = $this->applyTransformers($ast); 114 | 115 | $this->setFullyQualifiedName($ast); 116 | 117 | return (new Standard)->prettyPrintFile($ast); 118 | } 119 | 120 | protected function applyTransformers(array $ast): array 121 | { 122 | foreach ($this->transformers as $transformer) { 123 | $ast = $this->applyTransformer($ast, $transformer); 124 | } 125 | 126 | return $ast; 127 | } 128 | 129 | protected function applyTransformer($ast, $transformer): array 130 | { 131 | $traverser = new NodeTraverser; 132 | $traverser->addVisitor(new NodeConnectingVisitor); 133 | $transformer = new $transformer($this->filename); 134 | $traverser->addVisitor($transformer); 135 | 136 | return $transformer->shouldHandle($ast) ? $traverser->traverse($ast) : $ast; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Conformity/Transformers/ActionDiscoveryTransformer.php: -------------------------------------------------------------------------------- 1 | findFirst($ast, fn(Node $node) => $this->isExposeCall($node)) !== null 28 | && $this->hasProceduralTrait($ast); 29 | } 30 | 31 | public function enterNode(Node $node): ?Node 32 | { 33 | if ($this->isExposeCall($node)) { 34 | $this->extractMethodInfo($node); 35 | } 36 | 37 | return null; 38 | } 39 | 40 | public function afterTraverse(array $nodes): ?array 41 | { 42 | if (empty($this->methodsToCreate)) { 43 | return null; 44 | } 45 | 46 | $class = $this->findClass($nodes); 47 | if (!$class) { 48 | return null; 49 | } 50 | 51 | foreach ($this->methodsToCreate as $methodInfo) { 52 | $class->stmts[] = $this->createClassMethod($methodInfo); 53 | } 54 | 55 | return $nodes; 56 | } 57 | 58 | protected function isExposeCall(Node $node): bool 59 | { 60 | return $this->isThisMethodCall($node, 'expose'); 61 | } 62 | 63 | protected function extractMethodInfo(Node\Expr\MethodCall $node): void 64 | { 65 | foreach ($node->args as $arg) { 66 | $name = $arg->name->name; 67 | $handler = $arg->value; 68 | 69 | $params = []; 70 | if ($handler instanceof Node\Expr\Closure || $handler instanceof Node\Expr\ArrowFunction) { 71 | $params = $handler->params; 72 | } 73 | 74 | $this->methodsToCreate[] = [ 75 | 'name' => $name, 76 | 'params' => $params 77 | ]; 78 | } 79 | } 80 | 81 | protected function createClassMethod(array $methodInfo): ClassMethod 82 | { 83 | // Create call_user_func to invoke the stored action handler 84 | $callUserFunc = new FuncCall( 85 | new Name('call_user_func'), 86 | [ 87 | new Arg( 88 | new Node\Expr\ArrayDimFetch( 89 | new Node\Expr\PropertyFetch( 90 | new Variable('this'), 91 | 'actions' 92 | ), 93 | new FunctionName 94 | ) 95 | ), 96 | new Arg( 97 | new FuncCall( 98 | new Name('func_get_args') 99 | ), 100 | false, 101 | true 102 | ) 103 | ] 104 | ); 105 | 106 | return new ClassMethod( 107 | $methodInfo['name'], 108 | [ 109 | 'flags' => Modifiers::PUBLIC, 110 | 'params' => $methodInfo['params'], 111 | 'stmts' => [new Node\Stmt\Return_($callUserFunc)], 112 | 'attrGroups' => [ 113 | new AttributeGroup([ 114 | new Attribute( 115 | new Name\FullyQualified(Expose::class) 116 | ) 117 | ]) 118 | ] 119 | ] 120 | ); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Conformity/Transformers/AnonymousClassTransformer.php: -------------------------------------------------------------------------------- 1 | findFirst($ast, fn(Node $node) => $node instanceof Return_ && 26 | $node->expr instanceof Node\Expr\New_ && 27 | $node->expr->class instanceof Class_ && 28 | $node->expr->class->name === null 29 | ) !== null; 30 | } 31 | 32 | public function enterNode(Node $node): int|Node|null 33 | { 34 | // Capture use statements while removing them from the tree 35 | if ($node instanceof Node\Stmt\Use_) { 36 | $node->setAttribute('comments', null); 37 | $this->imports[] = $node; 38 | 39 | return NodeVisitor::REMOVE_NODE; 40 | } 41 | 42 | // Stop traversing when we hit the anonymous class return 43 | if ($node instanceof Return_ && 44 | $node->expr instanceof Node\Expr\New_ && 45 | $node->expr->class instanceof Class_ && 46 | $node->expr->class->name === null) { 47 | return NodeVisitor::DONT_TRAVERSE_CURRENT_AND_CHILDREN; 48 | } 49 | 50 | // Remove everything else 51 | return NodeVisitor::REMOVE_NODE; 52 | } 53 | 54 | public function leaveNode(Node $node): ?Node 55 | { 56 | if (!$this->isAnonymousClassReturn($node)) { 57 | return null; 58 | } 59 | 60 | return $this->createNamespacedClass($node); 61 | } 62 | 63 | protected function isAnonymousClassReturn(Node $node): bool 64 | { 65 | return $node instanceof Return_ && 66 | $node->expr instanceof Node\Expr\New_ && 67 | $node->expr->class instanceof Class_ && 68 | $node->expr->class->name === null; 69 | } 70 | 71 | protected function createNamespacedClass(Return_ $node): Namespace_ 72 | { 73 | $namespace = $this->generateNamespace(); 74 | $className = $this->generateClassName(); 75 | 76 | // Create the class 77 | $class = new Class_( 78 | $className, 79 | [ 80 | 'extends' => new Name\FullyQualified(config('fusion.base_page')), 81 | 'stmts' => $node->expr->class->stmts, 82 | ] 83 | ); 84 | 85 | // Create the namespace with imports and class 86 | $namespace = new Namespace_( 87 | new Name($namespace), 88 | [...$this->imports, $class] 89 | ); 90 | 91 | $namespace->setDocComment(new Doc($this->generateDocBlock())); 92 | 93 | return $namespace; 94 | } 95 | 96 | protected function generateClassName(): string 97 | { 98 | if (!$this->filename) { 99 | throw new InvalidArgumentException('Filename must be set.'); 100 | } 101 | 102 | return pathinfo($this->filename, PATHINFO_FILENAME); 103 | } 104 | 105 | protected function generateNamespace(): string 106 | { 107 | if (!$this->filename) { 108 | throw new InvalidArgumentException('Filename must be set.'); 109 | } 110 | 111 | $storage = Fusion::storage('PHP') . DIRECTORY_SEPARATOR; 112 | 113 | if (!str_contains($this->filename, $storage)) { 114 | throw new InvalidArgumentException("Path must contain [$storage]."); 115 | } 116 | 117 | return str($this->filename) 118 | ->after($storage) 119 | ->replace('/', '\\') 120 | ->prepend('Fusion\\Generated\\') 121 | ->beforeLast('\\') 122 | ->value(); 123 | } 124 | 125 | protected function generateDocBlock(): string 126 | { 127 | return << 5 | */ 6 | 7 | namespace Fusion\Conformity\Transformers; 8 | 9 | use PhpParser\Node; 10 | use PhpParser\Node\Expr\New_; 11 | use PhpParser\Node\Stmt\Expression; 12 | use PhpParser\Node\Stmt\Return_; 13 | 14 | class AnonymousReturnTransformer extends Transformer 15 | { 16 | public function shouldHandle(array $ast): bool 17 | { 18 | return $this->findFirst($ast, fn(Node $node) => $node instanceof Expression && 19 | $node->expr instanceof New_ && 20 | $node->expr->class instanceof Node\Stmt\Class_ && 21 | $node->expr->class->name === null && 22 | !($node->getAttribute('parent') instanceof Return_) 23 | ) !== null; 24 | } 25 | 26 | public function enterNode(Node $node): ?Node 27 | { 28 | if (!$node instanceof Expression || 29 | !$node->expr instanceof New_ || 30 | !$node->expr->class instanceof Node\Stmt\Class_ || 31 | $node->expr->class->name !== null || 32 | $node->getAttribute('parent') instanceof Return_) { 33 | return null; 34 | } 35 | 36 | return new Return_($node->expr); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Conformity/Transformers/ExposeTransformer.php: -------------------------------------------------------------------------------- 1 | findFirst($ast, fn(Node $node) => $this->isNamedFunction($node, 'expose') 16 | ) !== null; 17 | } 18 | 19 | public function enterNode(Node $node): ?Node 20 | { 21 | // Prevent assigning expose result to variable 22 | if ($this->isAssignmentNode($node) && $this->isNamedFunction($node->expr->expr, 'expose')) { 23 | throw new Exception('Cannot assign the result of `expose` to a variable.'); 24 | } 25 | 26 | // Only transform standalone expose() calls 27 | if (!$node instanceof Expression || !$this->isNamedFunction($node->expr, 'expose')) { 28 | return null; 29 | } 30 | 31 | foreach ($node->expr->args as $arg) { 32 | /** @var Node\Arg $arg */ 33 | if (is_null($arg->name)) { 34 | throw new Exception('Cannot expose an unnamed function.'); 35 | } 36 | } 37 | 38 | return new Expression(new MethodCall( 39 | new Variable('this'), 'expose', $node->expr->args 40 | )); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Conformity/Transformers/FunctionImportTransformer.php: -------------------------------------------------------------------------------- 1 | loadFunctionNames(); 20 | } 21 | 22 | public function shouldHandle(array $ast): bool 23 | { 24 | if (empty($this->functionNames)) { 25 | return false; 26 | } 27 | 28 | return $this->findFirst($ast, fn(Node $node) => $this->isFunctionUseStatement($node) || 29 | $this->hasNamedFunctionImport($node) 30 | ) !== null; 31 | } 32 | 33 | public function enterNode(Node $node): ?int 34 | { 35 | if ($this->isFunctionUseStatement($node)) { 36 | return $this->handleFunctionUse($node); 37 | } 38 | 39 | if ($node instanceof Node\Stmt\GroupUse) { 40 | return $this->handleGroupUse($node); 41 | } 42 | 43 | return null; 44 | } 45 | 46 | protected function loadFunctionNames(): void 47 | { 48 | $functionsPath = __DIR__ . '/../../../functions.php'; 49 | 50 | if (!file_exists($functionsPath)) { 51 | return; 52 | } 53 | 54 | try { 55 | $parser = (new \PhpParser\ParserFactory)->createForHostVersion(); 56 | $ast = $parser->parse(file_get_contents($functionsPath)); 57 | 58 | $functions = $this->find($ast, fn(Node $node) => $node instanceof Node\Stmt\Function_ 59 | ); 60 | 61 | $this->functionNames = array_map( 62 | fn(Node\Stmt\Function_ $function) => $function->name->toString(), 63 | $functions 64 | ); 65 | 66 | } catch (\Throwable) { 67 | $this->functionNames = []; 68 | } 69 | } 70 | 71 | protected function isFunctionUseStatement(Node $node): bool 72 | { 73 | return $node instanceof Use_ && 74 | $node->type === Use_::TYPE_FUNCTION && 75 | !empty($node->uses); 76 | } 77 | 78 | protected function hasNamedFunctionImport(Node $node): bool 79 | { 80 | if (!$node instanceof Node\Stmt\GroupUse) { 81 | return false; 82 | } 83 | 84 | if ($node->type === Use_::TYPE_FUNCTION) { 85 | return true; 86 | } 87 | 88 | foreach ($node->uses as $use) { 89 | if ($use->type === Use_::TYPE_FUNCTION || 90 | in_array($use->name->getLast(), $this->functionNames)) { 91 | return true; 92 | } 93 | } 94 | 95 | return false; 96 | } 97 | 98 | protected function handleFunctionUse(Use_ $node): ?int 99 | { 100 | $hasRemainingImports = false; 101 | 102 | foreach ($node->uses as $key => $use) { 103 | if (in_array($use->name->getLast(), $this->functionNames)) { 104 | unset($node->uses[$key]); 105 | } else { 106 | $hasRemainingImports = true; 107 | } 108 | } 109 | 110 | if ($hasRemainingImports) { 111 | $node->uses = array_values($node->uses); 112 | 113 | return null; 114 | } 115 | 116 | return NodeVisitor::REMOVE_NODE; 117 | } 118 | 119 | protected function handleGroupUse(Node\Stmt\GroupUse $node): ?int 120 | { 121 | $hasRemainingImports = false; 122 | 123 | foreach ($node->uses as $key => $use) { 124 | if ($use->type === Use_::TYPE_FUNCTION || 125 | in_array($use->name->getLast(), $this->functionNames)) { 126 | unset($node->uses[$key]); 127 | } else { 128 | $hasRemainingImports = true; 129 | } 130 | } 131 | 132 | if ($hasRemainingImports) { 133 | $node->uses = array_values($node->uses); 134 | 135 | return null; 136 | } 137 | 138 | return NodeVisitor::REMOVE_NODE; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Conformity/Transformers/MountTransformer.php: -------------------------------------------------------------------------------- 1 | findFirst($ast, fn(Node $node) => $this->isNamedFunction($node, 'mount') 16 | ) !== null; 17 | } 18 | 19 | public function enterNode(Node $node): ?Node 20 | { 21 | // Transform assignment of mount() result 22 | if ($this->isAssignmentNode($node)) { 23 | if ($this->isNamedFunction($node->expr->expr, 'mount')) { 24 | return $this->transformMountAssignment($node); 25 | } 26 | 27 | return null; 28 | } 29 | 30 | // Transform standalone mount() call 31 | if ($node instanceof Expression && $this->isNamedFunction($node->expr, 'mount')) { 32 | return new Expression($this->transformMountCall($node->expr)); 33 | } 34 | 35 | return null; 36 | } 37 | 38 | protected function transformMountAssignment(Expression $node): Node 39 | { 40 | $assignment = $node->expr; 41 | $mountCall = $assignment->expr; 42 | 43 | $this->validateMountArguments($mountCall->args); 44 | $assignment->expr = $this->transformMountCall($mountCall); 45 | 46 | return $node; 47 | } 48 | 49 | protected function transformMountCall(Node $node): MethodCall 50 | { 51 | $this->validateMountArguments($node->args); 52 | 53 | return new MethodCall( 54 | new Variable('this'), 55 | 'mount', 56 | $node->args 57 | ); 58 | } 59 | 60 | protected function validateMountArguments(array $args): void 61 | { 62 | if (count($args) !== 1) { 63 | throw new Exception('Mount function must have exactly one argument.'); 64 | } 65 | 66 | $arg = $args[0]->value; 67 | if (!$arg instanceof Node\Expr\Closure && !$arg instanceof Node\Expr\ArrowFunction) { 68 | throw new Exception( 69 | 'Mount function argument must be an anonymous function or arrow function.' 70 | ); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Conformity/Transformers/ProceduralTransformer.php: -------------------------------------------------------------------------------- 1 | findClass($ast) === null; 36 | } 37 | 38 | public function enterNode(Node $node): ?int 39 | { 40 | // Collect use statements separately 41 | if ($node instanceof Use_) { 42 | $this->useStatements[] = $node; 43 | 44 | return NodeVisitor::REMOVE_NODE; 45 | } 46 | 47 | // Collect all statement nodes for our method body 48 | if ($node instanceof Node\Stmt) { 49 | $this->statements[] = $node; 50 | 51 | return NodeVisitor::REMOVE_NODE; 52 | } 53 | 54 | return null; 55 | } 56 | 57 | public function afterTraverse(array $nodes): array 58 | { 59 | // Create the sync props call that needs to be at the end 60 | $syncProps = new Expression( 61 | new MethodCall( 62 | new Variable('this'), 63 | 'syncProps', 64 | [ 65 | new Node\Arg( 66 | new Node\Expr\FuncCall( 67 | new Node\Name('get_defined_vars') 68 | ) 69 | ) 70 | ] 71 | ) 72 | ); 73 | 74 | // Create the runProceduralCode method with collected statements 75 | $method = new ClassMethod( 76 | 'runProceduralCode', 77 | [ 78 | 'flags' => Modifiers::PUBLIC, 79 | 'stmts' => [...$this->statements, $syncProps] 80 | ] 81 | ); 82 | 83 | // Create the trait use statement 84 | $traitUse = new TraitUse([ 85 | new Name\FullyQualified(IsProceduralPage::class) 86 | ]); 87 | 88 | // Create the anonymous class extending FusionPage 89 | $class = new Class_( 90 | null, 91 | [ 92 | 'extends' => new Name(FusionPage::class), 93 | 'stmts' => [$traitUse, $method] 94 | ] 95 | ); 96 | 97 | // Return the use statements followed by class instantiation 98 | return [ 99 | ...$this->useStatements, 100 | new Return_(new New_($class)) 101 | ]; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Conformity/Transformers/PropDiscoveryTransformer.php: -------------------------------------------------------------------------------- 1 | hasProceduralTrait($ast); 21 | } 22 | 23 | public function beforeTraverse(array $nodes): ?array 24 | { 25 | $this->discoveries = []; 26 | 27 | return null; 28 | } 29 | 30 | public function enterNode(Node $node): ?Node 31 | { 32 | if ($this->isPropCall($node)) { 33 | $this->extractPropName($node); 34 | } 35 | 36 | return null; 37 | } 38 | 39 | public function afterTraverse(array $nodes): ?array 40 | { 41 | if (empty($this->discoveries)) { 42 | return null; 43 | } 44 | 45 | $class = $this->findClass($nodes); 46 | if (!$class) { 47 | return null; 48 | } 49 | 50 | // Create property values array with discovered prop names 51 | $values = array_map( 52 | fn($discovery) => new Node\Expr\ArrayItem(new String_($discovery)), 53 | array_unique($this->discoveries) 54 | ); 55 | 56 | // Create the property with ServerOnly attribute 57 | $property = $this->createClassProperty( 58 | 'discoveredProps', 59 | new Array_($values), 60 | modifiers: Modifiers::PUBLIC, 61 | attributes: [ServerOnly::class] 62 | ); 63 | 64 | $this->addPropertyToClass($class, $property); 65 | 66 | return $nodes; 67 | } 68 | 69 | protected function isPropCall(Node $node): bool 70 | { 71 | return $this->isThisMethodCall($node, 'prop'); 72 | } 73 | 74 | protected function extractPropName(Node\Expr\MethodCall $node): void 75 | { 76 | foreach ($node->args as $arg) { 77 | if (isset($arg->name) && 78 | $arg->name->name === 'name' && 79 | $arg->value instanceof String_) { 80 | $this->discoveries[] = $arg->value->value; 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Conformity/Transformers/PropTransformer.php: -------------------------------------------------------------------------------- 1 | findFirst($ast, fn(Node $node) => $this->isNamedFunction($node, 'prop') 16 | ) !== null; 17 | } 18 | 19 | public function enterNode(Node $node): ?Node 20 | { 21 | // Handle standalone or chained calls 22 | if ($this->isPropNode($node)) { 23 | return $this->transformStandaloneProp($node); 24 | } 25 | 26 | // Handle assignments like `$variable = prop(...)` 27 | if ($this->isAssignmentNode($node) && $this->isPropNode($node->expr->expr)) { 28 | return $this->transformPropAssignment($node); 29 | } 30 | 31 | return null; 32 | } 33 | 34 | protected function isPropNode(Node $node): bool 35 | { 36 | // Direct prop() call 37 | if ($this->isNamedFunction($node, 'prop')) { 38 | return true; 39 | } 40 | 41 | // Method chain starting with prop() 42 | if ($node instanceof MethodCall) { 43 | $var = $node->var; 44 | while ($var instanceof MethodCall) { 45 | $var = $var->var; 46 | } 47 | 48 | return $this->isNamedFunction($var, 'prop'); 49 | } 50 | 51 | return false; 52 | } 53 | 54 | protected function transformStandaloneProp(Node $node): MethodCall 55 | { 56 | return $this->buildPropMethodCall($node, null); 57 | } 58 | 59 | protected function transformPropAssignment(Expression $node): Node 60 | { 61 | $assignment = $node->expr; 62 | $propExpr = $assignment->expr; 63 | $varName = $assignment->var->name; 64 | 65 | // Build call with assigned variable name 66 | $assignment->expr = $this->buildPropMethodCall($propExpr, $varName); 67 | 68 | return $node; 69 | } 70 | 71 | protected function buildPropMethodCall(Node $node, ?string $assignedVarName): MethodCall 72 | { 73 | // Extract base call and method chain 74 | [$basePropCall, $methodChains] = $this->extractMethodChains($node); 75 | 76 | // Build final prop call 77 | $propCall = $this->transformPropBase($basePropCall, $assignedVarName); 78 | 79 | // Re-apply any method chains 80 | foreach ($methodChains as $chain) { 81 | $propCall = new MethodCall($propCall, $chain['name'], $chain['args']); 82 | } 83 | 84 | return $propCall; 85 | } 86 | 87 | protected function extractMethodChains(Node $node): array 88 | { 89 | $methodChains = []; 90 | $basePropCall = $node; 91 | 92 | while ($basePropCall instanceof MethodCall) { 93 | if ($this->isNamedFunction($basePropCall, 'prop')) { 94 | break; 95 | } 96 | 97 | $methodChains[] = [ 98 | 'name' => $basePropCall->name->name, 99 | 'args' => $basePropCall->args, 100 | ]; 101 | 102 | $basePropCall = $basePropCall->var; 103 | } 104 | 105 | return [$basePropCall, array_reverse($methodChains)]; 106 | } 107 | 108 | protected function transformPropBase(Node $basePropCall, ?string $assignedVarName): MethodCall 109 | { 110 | $originalArgs = $basePropCall->args; 111 | 112 | // Keep original args if explicitly named prop/default with no assigned name 113 | if ($assignedVarName === null && 114 | count($originalArgs) === 2 && 115 | !isset($originalArgs[0]->name) && 116 | !isset($originalArgs[1]->name)) { 117 | return new MethodCall( 118 | new Variable('this'), 119 | 'prop', 120 | $originalArgs 121 | ); 122 | } 123 | 124 | return new MethodCall( 125 | new Variable('this'), 126 | 'prop', 127 | $this->buildPropArguments($originalArgs, $assignedVarName) 128 | ); 129 | } 130 | 131 | protected function buildPropArguments(array $originalArgs, ?string $assignedVarName): array 132 | { 133 | $nameArg = null; 134 | $defaultValue = null; 135 | 136 | // Extract name and default from original args 137 | foreach ($originalArgs as $arg) { 138 | if (isset($arg->name) && $arg->name->name === 'name') { 139 | $nameArg = $arg; 140 | } elseif (!isset($arg->name) && $defaultValue === null) { 141 | $defaultValue = $arg->value; 142 | } elseif (isset($arg->name) && $arg->name->name === 'default') { 143 | $defaultValue = $arg->value; 144 | } 145 | } 146 | 147 | $newArgs = []; 148 | 149 | // Build name argument 150 | if ($nameArg) { 151 | $newArgs[] = $nameArg; 152 | } elseif ($assignedVarName) { 153 | $newArgs[] = $this->createNamedArgument(new String_($assignedVarName), 'name'); 154 | } elseif ($defaultValue instanceof Variable) { 155 | $newArgs[] = $this->createNamedArgument(new String_($defaultValue->name), 'name'); 156 | } 157 | 158 | // Add default argument 159 | if ($defaultValue) { 160 | $newArgs[] = $this->createNamedArgument($defaultValue, 'default'); 161 | } 162 | 163 | // Add any additional named args 164 | foreach ($originalArgs as $arg) { 165 | if (isset($arg->name) && !in_array($arg->name->name, ['name', 'default'])) { 166 | $newArgs[] = $arg; 167 | } 168 | } 169 | 170 | return $newArgs; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/Conformity/Transformers/PropValueTransformer.php: -------------------------------------------------------------------------------- 1 | findFirst($ast, fn(Node $node) => $this->isThisPropCall($node) 15 | ) !== null; 16 | } 17 | 18 | /** 19 | * Find all method call chains that start with $this->prop() 20 | * and ensure they end with ->value() 21 | */ 22 | public function afterTraverse(array $nodes): ?array 23 | { 24 | // Find all outermost method calls 25 | $outermostCalls = $this->find($nodes, fn(Node $n) => $n instanceof MethodCall && 26 | !($n->getAttribute('parent') instanceof MethodCall) 27 | ); 28 | 29 | foreach ($outermostCalls as $call) { 30 | $this->processOutermostCall($call); 31 | } 32 | 33 | return $nodes; 34 | } 35 | 36 | protected function processOutermostCall(MethodCall $outermostCall): void 37 | { 38 | // Get the chain from outermost to innermost 39 | $chain = $this->gatherChain($outermostCall); 40 | if (empty($chain)) { 41 | return; 42 | } 43 | 44 | // Check the innermost call is $this->prop() 45 | $innermost = end($chain); 46 | if (!$this->isThisPropCall($innermost)) { 47 | return; 48 | } 49 | 50 | // Skip if ->value() already exists 51 | if ($this->chainHasValueCall($chain)) { 52 | return; 53 | } 54 | 55 | // Add ->value() to the chain 56 | $newCall = $this->addValueToChain($chain); 57 | 58 | // Replace the old call in the AST 59 | $this->replaceNode($outermostCall, $newCall); 60 | } 61 | 62 | protected function isThisPropCall(Node $node): bool 63 | { 64 | return $node instanceof MethodCall && 65 | $node->name instanceof Identifier && 66 | $node->name->name === 'prop' && 67 | $node->var instanceof Variable && 68 | $node->var->name === 'this'; 69 | } 70 | 71 | protected function gatherChain(MethodCall $start): array 72 | { 73 | $chain = []; 74 | $current = $start; 75 | 76 | while ($current instanceof MethodCall) { 77 | $chain[] = $current; 78 | $current = $current->var; 79 | } 80 | 81 | return $chain; 82 | } 83 | 84 | protected function chainHasValueCall(array $chain): bool 85 | { 86 | foreach ($chain as $call) { 87 | if ($call->name instanceof Identifier && 88 | $call->name->name === 'value') { 89 | return true; 90 | } 91 | } 92 | 93 | return false; 94 | } 95 | 96 | protected function addValueToChain(array $chain): MethodCall 97 | { 98 | // Rebuild chain in same order and add value() at end 99 | $rebuilt = end($chain); 100 | 101 | for ($i = count($chain) - 2; $i >= 0; $i--) { 102 | $call = $chain[$i]; 103 | $rebuilt = new MethodCall( 104 | $rebuilt, 105 | $call->name, 106 | $call->args, 107 | $call->getAttributes() 108 | ); 109 | } 110 | 111 | return new MethodCall($rebuilt, 'value'); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Conformity/Transformers/Transformer.php: -------------------------------------------------------------------------------- 1 | finder = new NodeFinder; 31 | } 32 | 33 | /** 34 | * Helper to find nodes in the AST that match a condition 35 | */ 36 | protected function find(array $ast, callable $filter): array 37 | { 38 | return $this->finder->find($ast, $filter); 39 | } 40 | 41 | /** 42 | * Helper to find a single node in the AST that matches a condition 43 | */ 44 | protected function findFirst(array $ast, callable $filter): ?Node 45 | { 46 | return $this->finder->findFirst($ast, $filter); 47 | } 48 | 49 | /** 50 | * Check if a node matches a specific function name 51 | */ 52 | protected function isNamedFunction(Node $node, string $name): bool 53 | { 54 | return $node instanceof FuncCall 55 | && $node->name instanceof Name 56 | && $node->name->toString() === $name; 57 | } 58 | 59 | /** 60 | * Check if a node is a method call with a specific name 61 | */ 62 | protected function isMethodCall(Node $node, string $method): bool 63 | { 64 | return $node instanceof MethodCall 65 | && $node->name instanceof Identifier 66 | && $node->name->name === $method; 67 | } 68 | 69 | /** 70 | * Check if a node is a method call on $this with a specific name 71 | */ 72 | protected function isThisMethodCall(Node $node, string $method): bool 73 | { 74 | return $this->isMethodCall($node, $method) 75 | && $node->var instanceof Variable 76 | && $node->var->name === 'this'; 77 | } 78 | 79 | /** 80 | * Check if class uses the procedural trait 81 | */ 82 | protected function hasProceduralTrait(array $ast): bool 83 | { 84 | return $this->findFirst($ast, function (Node $node) use ($ast) { 85 | if (!$node instanceof TraitUse) { 86 | return false; 87 | } 88 | 89 | foreach ($node->traits as $trait) { 90 | $name = ltrim($trait->toString(), '\\'); 91 | 92 | if ($name === self::PROCEDURAL_TRAIT_FQCN) { 93 | return true; 94 | } 95 | 96 | if ($name === 'IsProceduralPage') { 97 | return collect($ast) 98 | ->filter(fn($n) => $n instanceof Node\Stmt\Use_) 99 | ->contains(fn($use) => $use->uses[0]->name->toString() === self::PROCEDURAL_TRAIT_FQCN); 100 | } 101 | } 102 | 103 | return false; 104 | }) !== null; 105 | } 106 | 107 | /** 108 | * Create a property with attributes on a class 109 | */ 110 | protected function createClassProperty( 111 | string $name, 112 | mixed $value, 113 | int $modifiers = Modifiers::PUBLIC, 114 | array $attributes = [] 115 | ): Property { 116 | return new Property( 117 | $modifiers, 118 | [ 119 | new Node\Stmt\PropertyProperty( 120 | $name, 121 | $value 122 | ) 123 | ], 124 | [], 125 | new Node\Identifier('array'), 126 | array_map( 127 | fn($attribute) => new AttributeGroup([ 128 | new Attribute(new Name("\\$attribute")) 129 | ]), 130 | $attributes 131 | ) 132 | ); 133 | } 134 | 135 | /** 136 | * Find the main class in the AST 137 | */ 138 | protected function findClass(array $nodes): ?Class_ 139 | { 140 | return $this->findFirst($nodes, fn($n) => $n instanceof Class_); 141 | } 142 | 143 | /** 144 | * Add a property to the start of a class 145 | */ 146 | protected function addPropertyToClass(Class_ $class, Property $property): void 147 | { 148 | array_unshift($class->stmts, $property); 149 | } 150 | 151 | /** 152 | * Extract a literal value from a node 153 | */ 154 | protected function extractValueFromNode(Node $node): mixed 155 | { 156 | $nodeTypeHandlers = [ 157 | Node\Scalar\String_::class => fn($n) => $n->value, 158 | Node\Scalar\LNumber::class => fn($n) => $n->value, 159 | Node\Scalar\DNumber::class => fn($n) => $n->value, 160 | Node\Expr\Array_::class => fn($n) => $this->convertArrayNodeToPhp($n), 161 | Node\Expr\ConstFetch::class => fn($n) => $this->handleConstFetch($n), 162 | ]; 163 | 164 | $nodeType = get_class($node); 165 | 166 | if (!isset($nodeTypeHandlers[$nodeType])) { 167 | throw new InvalidArgumentException("Unsupported node type: {$nodeType}"); 168 | } 169 | 170 | return $nodeTypeHandlers[$nodeType]($node); 171 | } 172 | 173 | /** 174 | * Convert an array node to a PHP array 175 | */ 176 | protected function convertArrayNodeToPhp(Node\Expr\Array_ $node): array 177 | { 178 | $result = []; 179 | 180 | foreach ($node->items as $item) { 181 | $value = $this->extractValueFromNode($item->value); 182 | 183 | if ($item->key !== null) { 184 | $key = $this->extractValueFromNode($item->key); 185 | $result[$key] = $value; 186 | } else { 187 | $result[] = $value; 188 | } 189 | } 190 | 191 | return $result; 192 | } 193 | 194 | /** 195 | * Handle constant fetch nodes 196 | */ 197 | protected function handleConstFetch(Node\Expr\ConstFetch $node): mixed 198 | { 199 | $constMap = [ 200 | 'true' => true, 201 | 'false' => false, 202 | 'null' => null, 203 | ]; 204 | 205 | $name = $node->name->toLowerString(); 206 | 207 | if (!isset($constMap[$name])) { 208 | throw new InvalidArgumentException("Unsupported constant: {$name}"); 209 | } 210 | 211 | return $constMap[$name]; 212 | } 213 | 214 | /** 215 | * Create a named argument for a method call 216 | */ 217 | protected function createNamedArgument(Node $value, string $name): Node\Arg 218 | { 219 | return new Node\Arg($value, false, false, [], new Identifier($name)); 220 | } 221 | 222 | /** 223 | * Replace a node in the AST 224 | */ 225 | protected function replaceNode(Node $oldNode, Node $newNode): void 226 | { 227 | $parent = $oldNode->getAttribute('parent'); 228 | if (!$parent) { 229 | return; 230 | } 231 | 232 | foreach ($parent->getSubNodeNames() as $subName) { 233 | $subNode = $parent->{$subName}; 234 | 235 | if ($subNode === $oldNode) { 236 | $parent->{$subName} = $newNode; 237 | 238 | return; 239 | } 240 | 241 | if (is_array($subNode)) { 242 | foreach ($subNode as $k => $item) { 243 | if ($item === $oldNode) { 244 | $subNode[$k] = $newNode; 245 | $parent->{$subName} = $subNode; 246 | 247 | return; 248 | } 249 | } 250 | } 251 | } 252 | } 253 | 254 | /** 255 | * Determine if a node type matches the expected type 256 | */ 257 | protected function isNodeType(Node $node, string $type): bool 258 | { 259 | return $node instanceof $type; 260 | } 261 | 262 | /** 263 | * Check if a node is an assignment, optionally to a specific variable name 264 | */ 265 | protected function isAssignmentNode(Node $node, ?string $variableName = null): bool 266 | { 267 | $isAssignment = $node instanceof Node\Stmt\Expression 268 | && $node->expr instanceof Node\Expr\Assign; 269 | 270 | if (!$isAssignment || $variableName === null) { 271 | return $isAssignment; 272 | } 273 | 274 | return $isAssignment 275 | && $node->expr->var instanceof Node\Expr\Variable 276 | && $node->expr->var->name === $variableName; 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/Conformity/Transformers/TransformerInterface.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Conformity\Transformers; 8 | 9 | interface TransformerInterface 10 | { 11 | /** 12 | * Determine if this transformer should handle the given AST 13 | * 14 | * @param array $ast The PHP Parser AST 15 | */ 16 | public function shouldHandle(array $ast): bool; 17 | } 18 | -------------------------------------------------------------------------------- /src/Console/Actions/AddViteConfig.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Console\Actions; 8 | 9 | use Illuminate\Console\Concerns\InteractsWithIO; 10 | use Illuminate\Support\Facades\File; 11 | use Symfony\Component\Console\Input\InputInterface; 12 | use Symfony\Component\Console\Output\OutputInterface; 13 | 14 | class AddViteConfig 15 | { 16 | use InteractsWithIO; 17 | 18 | public function __construct(InputInterface $input, OutputInterface $output) 19 | { 20 | $this->input = $input; 21 | $this->output = $output; 22 | } 23 | 24 | public function handle() 25 | { 26 | $configPath = base_path('vite.config.js'); 27 | 28 | // Check if vite.config.js exists 29 | if (!File::exists($configPath)) { 30 | $this->error('[Vite] vite.config.js not found in the project root!'); 31 | 32 | return 1; 33 | } 34 | 35 | $content = File::get($configPath); 36 | 37 | // Check if fusion is already imported 38 | if (str_contains($content, '@fusion/vue/vite')) { 39 | $this->info('[Vite] Fusion plugin is already imported!'); 40 | 41 | return 0; 42 | } 43 | 44 | // Create backup only if we need to make changes 45 | $backupPath = base_path('vite.config.js.backup'); 46 | File::copy($configPath, $backupPath); 47 | 48 | try { 49 | // Add fusion import 50 | $content = $this->addFusionImport($content); 51 | 52 | // Add fusion plugin to plugins array 53 | $content = $this->addFusionPlugin($content); 54 | 55 | // Write modified content back to file 56 | File::put($configPath, $content); 57 | 58 | $this->info('[Vite] Successfully added Fusion plugin to vite.config.js'); 59 | $this->info('[Vite] Backup created at vite.config.js.backup'); 60 | 61 | return 0; 62 | } catch (\Exception $e) { 63 | // Restore from backup if something goes wrong 64 | if (File::exists($backupPath)) { 65 | File::copy($backupPath, $configPath); 66 | $this->error('[Vite] An error occurred. The original file has been restored from backup.'); 67 | $this->error($e->getMessage()); 68 | } 69 | 70 | return 1; 71 | } 72 | } 73 | 74 | private function addFusionImport(string $content): string 75 | { 76 | // Find the last import statement 77 | preg_match_all('/^import .+$/m', $content, $matches); 78 | 79 | if (empty($matches[0])) { 80 | throw new \Exception('Could not find import statements in vite.config.js'); 81 | } 82 | 83 | $lastImport = end($matches[0]); 84 | 85 | // Add fusion import after the last import 86 | return str_replace( 87 | $lastImport, 88 | $lastImport . "\nimport fusion from '@fusion/vue/vite';", 89 | $content 90 | ); 91 | } 92 | 93 | private function addFusionPlugin(string $content): string 94 | { 95 | // Extract the plugins array content 96 | if (!preg_match('/plugins:\s*\[(.*?)\]/s', $content, $matches)) { 97 | throw new \Exception('Could not find plugins array in vite.config.js'); 98 | } 99 | 100 | // Get the current indentation level 101 | preg_match('/^(\s+)plugins:/m', $content, $indentMatches); 102 | $baseIndent = $indentMatches[1] ?? ' '; 103 | $pluginIndent = $baseIndent . ' '; 104 | 105 | // Format the fusion plugin with proper indentation 106 | $fusionPlugin = "\n" . $pluginIndent . 'fusion(),'; 107 | 108 | // Find the position right after the opening bracket of the plugins array 109 | $pluginsStart = strpos($content, 'plugins: [') + strlen('plugins: ['); 110 | 111 | // Insert the fusion plugin 112 | $content = substr_replace($content, $fusionPlugin, $pluginsStart, 0); 113 | 114 | // Clean up any extra newlines that might have been created 115 | $content = preg_replace('/\n\s*\n\s*\n/', "\n\n", $content); 116 | 117 | // Ensure consistent spacing around brackets 118 | $content = preg_replace('/\[\s+\n/', "[\n", $content); 119 | $content = preg_replace('/,\s*\n\s*\]/', "\n" . $baseIndent . ']', $content); 120 | 121 | return $content; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Console/Actions/AddVuePackage.php: -------------------------------------------------------------------------------- 1 | input = $input; 27 | $this->output = $output; 28 | $this->packagePath = base_path('package.json'); 29 | $this->backupPath = base_path('package.json.backup'); 30 | } 31 | 32 | public function handle() 33 | { 34 | if (!File::exists($this->packagePath)) { 35 | $this->error('[Package] package.json not found in the project root!'); 36 | 37 | return 1; 38 | } 39 | 40 | $content = File::get($this->packagePath); 41 | $json = json_decode($content, true); 42 | 43 | if (json_last_error() !== JSON_ERROR_NONE) { 44 | $this->error('[Package] Invalid JSON in package.json!'); 45 | 46 | return 1; 47 | } 48 | 49 | // Create backup before making any changes 50 | File::copy($this->packagePath, $this->backupPath); 51 | 52 | try { 53 | $modified = false; 54 | 55 | // Add package to devDependencies if needed 56 | if (!$this->isPackageAlreadyInstalled($json)) { 57 | if (!isset($json['devDependencies'])) { 58 | $json['devDependencies'] = []; 59 | } 60 | 61 | $json['devDependencies'][$this->packageName] = $this->packageVersion; 62 | ksort($json['devDependencies']); 63 | $modified = true; 64 | } 65 | 66 | // Add scripts if needed 67 | if ($this->addScripts($json)) { 68 | $modified = true; 69 | } 70 | 71 | if ($modified) { 72 | // Write back to file with consistent formatting 73 | $newContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 74 | File::put($this->packagePath, $newContent . "\n"); 75 | $this->info('[Package] Successfully updated package.json'); 76 | $this->info('[Package] Backup created at package.json.backup'); 77 | } else { 78 | $this->info('[Package] No changes needed in package.json'); 79 | File::delete($this->backupPath); 80 | } 81 | 82 | return 0; 83 | } catch (\Exception $e) { 84 | $this->restoreBackup($e->getMessage()); 85 | 86 | return 1; 87 | } 88 | } 89 | 90 | private function addScripts(array &$json): bool 91 | { 92 | $modified = false; 93 | 94 | // Ensure scripts section exists 95 | if (!isset($json['scripts'])) { 96 | $json['scripts'] = []; 97 | } 98 | 99 | // Add fusion:install script if it doesn't exist 100 | if (!isset($json['scripts']['fusion:install'])) { 101 | $json['scripts']['fusion:install'] = $this->fusionInstallScript; 102 | $modified = true; 103 | } 104 | 105 | // Handle postinstall script 106 | if (!isset($json['scripts']['postinstall'])) { 107 | // No existing postinstall, just add ours 108 | $json['scripts']['postinstall'] = 'npm run fusion:install'; 109 | $modified = true; 110 | } else { 111 | // Check if fusion:install is already in postinstall 112 | $currentPostinstall = $json['scripts']['postinstall']; 113 | if (!str_contains($currentPostinstall, 'npm run fusion:install')) { 114 | // Append our command to existing postinstall 115 | $json['scripts']['postinstall'] = "$currentPostinstall && npm run fusion:install"; 116 | 117 | $modified = true; 118 | } 119 | } 120 | 121 | return $modified; 122 | } 123 | 124 | protected function isPackageAlreadyInstalled(array $json): bool 125 | { 126 | return isset($json['devDependencies'][$this->packageName]) && 127 | $json['devDependencies'][$this->packageName] === $this->packageVersion; 128 | } 129 | 130 | protected function restoreBackup(string $errorMessage): void 131 | { 132 | if (File::exists($this->backupPath)) { 133 | File::copy($this->backupPath, $this->packagePath); 134 | $this->error('[Package] An error occurred. The original file has been restored from backup.'); 135 | $this->error($errorMessage); 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Console/Actions/AddVuePlugin.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Console\Actions; 8 | 9 | use Illuminate\Console\Concerns\InteractsWithIO; 10 | use Illuminate\Support\Facades\File; 11 | use Symfony\Component\Console\Input\InputInterface; 12 | use Symfony\Component\Console\Output\OutputInterface; 13 | 14 | class AddVuePlugin 15 | { 16 | use InteractsWithIO; 17 | 18 | public function __construct(InputInterface $input, OutputInterface $output) 19 | { 20 | $this->input = $input; 21 | $this->output = $output; 22 | } 23 | 24 | public function handle() 25 | { 26 | $appPath = base_path('resources/js/app.js'); 27 | 28 | // Check if app.js exists 29 | if (!File::exists($appPath)) { 30 | $this->error('resources/js/app.js not found!'); 31 | 32 | return 1; 33 | } 34 | 35 | $content = File::get($appPath); 36 | 37 | // Check if fusion is already imported 38 | if (str_contains($content, '@fusion/vue/vue')) { 39 | $this->info('[Vue] Fusion is already imported in app.js!'); 40 | 41 | return 0; 42 | } 43 | 44 | // Create backup only if we need to make changes 45 | $backupPath = base_path('resources/js/app.js.backup'); 46 | File::copy($appPath, $backupPath); 47 | 48 | try { 49 | // Add fusion import 50 | $content = $this->addFusionImport($content); 51 | 52 | // Add fusion plugin to createApp chain 53 | $content = $this->addFusionPlugin($content); 54 | 55 | // Write modified content back to file 56 | File::put($appPath, $content); 57 | 58 | $this->info('[Vue] Successfully added Fusion to app.js'); 59 | $this->info('[Vue] Backup created at resources/js/app.js.backup'); 60 | 61 | return 0; 62 | } catch (\Exception $e) { 63 | // Restore from backup if something goes wrong 64 | if (File::exists($backupPath)) { 65 | File::copy($backupPath, $appPath); 66 | $this->error('[Vue] An error occurred. The original file has been restored from backup.'); 67 | $this->error($e->getMessage()); 68 | } 69 | 70 | return 1; 71 | } 72 | } 73 | 74 | private function addFusionImport(string $content): string 75 | { 76 | // Find the last import statement 77 | preg_match_all('/^import .+$/m', $content, $matches); 78 | 79 | if (empty($matches[0])) { 80 | throw new \Exception('Could not find import statements in app.js'); 81 | } 82 | 83 | $lastImport = end($matches[0]); 84 | 85 | // Add fusion import after the last import 86 | return str_replace( 87 | $lastImport, 88 | $lastImport . "\nimport fusion from '@fusion/vue/vue';", 89 | $content 90 | ); 91 | } 92 | 93 | private function addFusionPlugin(string $content): string 94 | { 95 | // Find the createApp chain 96 | if (!preg_match('/return createApp.*?mount\(el\);/s', $content, $matches)) { 97 | throw new \Exception('Could not find createApp chain in app.js'); 98 | } 99 | 100 | $createAppChain = $matches[0]; 101 | 102 | // Find the last .use() or createApp() before .mount() 103 | if (!preg_match('/(.+?)\.mount\(el\);$/s', $createAppChain, $matches)) { 104 | throw new \Exception('Could not parse createApp chain structure'); 105 | } 106 | 107 | $beforeMount = $matches[1]; 108 | 109 | // Find the base indentation of the return statement 110 | preg_match('/^(\s+)return createApp/m', $content, $indentMatches); 111 | $baseIndent = $indentMatches[1] ?? ' '; 112 | 113 | // Calculate the chain indentation (2 spaces more than the previous line) 114 | $chainIndent = $baseIndent . ' '; 115 | 116 | // Add .use(fusion) with proper indentation 117 | $modifiedChain = $beforeMount . "\n" . $chainIndent . '.use(fusion)' . "\n" . $chainIndent . '.mount(el);'; 118 | 119 | return str_replace($createAppChain, $modifiedChain, $content); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Console/Actions/ModifyComposer.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Console\Actions; 8 | 9 | use Fusion\Fusion; 10 | use Illuminate\Console\Concerns\InteractsWithIO; 11 | use Illuminate\Support\Facades\File; 12 | use Symfony\Component\Console\Input\InputInterface; 13 | use Symfony\Component\Console\Output\OutputInterface; 14 | use Symfony\Component\Process\Process; 15 | 16 | class ModifyComposer 17 | { 18 | use InteractsWithIO; 19 | 20 | private string $composerPath; 21 | 22 | private string $backupPath; 23 | 24 | public string $postInstallCommand = '@php artisan fusion:post-install'; 25 | 26 | public function __construct(InputInterface $input, OutputInterface $output) 27 | { 28 | $this->input = $input; 29 | $this->output = $output; 30 | $this->composerPath = base_path('composer.json'); 31 | $this->backupPath = base_path('composer.json.backup'); 32 | } 33 | 34 | public function handle() 35 | { 36 | if (!File::exists($this->composerPath)) { 37 | $this->error('[Composer] composer.json not found in the project root!'); 38 | 39 | return 1; 40 | } 41 | 42 | $content = File::get($this->composerPath); 43 | $json = json_decode($content, true); 44 | 45 | if (json_last_error() !== JSON_ERROR_NONE) { 46 | $this->error('[Composer] Invalid JSON in composer.json!'); 47 | 48 | return 1; 49 | } 50 | 51 | File::copy($this->composerPath, $this->backupPath); 52 | 53 | try { 54 | $modified = false; 55 | 56 | // Add Generated namespace 57 | $modified = $this->addGeneratedNamespace($json) || $modified; 58 | 59 | // Update composer scripts 60 | $modified = $this->updateComposerScripts($json) || $modified; 61 | 62 | if (!$modified) { 63 | $this->info('[Composer] Composer.json is already up to date!'); 64 | 65 | return 0; 66 | } 67 | 68 | $newContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 69 | File::put($this->composerPath, $newContent . "\n"); 70 | 71 | $this->info('[Composer] Successfully updated composer.json'); 72 | $this->info('[Composer] Backup created at composer.json.backup'); 73 | 74 | $this->runComposerDumpAutoload(); 75 | 76 | return 0; 77 | } catch (\Exception $e) { 78 | $this->restoreBackup($e->getMessage()); 79 | 80 | return 1; 81 | } 82 | } 83 | 84 | private function addGeneratedNamespace(array &$json): bool 85 | { 86 | $namespace = 'Fusion\\Generated\\'; 87 | $path = Fusion::storage('PHP'); 88 | 89 | File::ensureDirectoryExists($path); 90 | 91 | $path = str($path)->replaceFirst(base_path(), '')->chopStart('/')->value(); 92 | 93 | $currentPath = $json['autoload']['psr-4'][$namespace] ?? null; 94 | 95 | if ($currentPath === $path) { 96 | $this->info('[Composer] Fusion PSR-4 autoload entry already exists with correct path.'); 97 | 98 | return false; 99 | } 100 | 101 | $json['autoload']['psr-4'][$namespace] = $path; 102 | 103 | return true; 104 | } 105 | 106 | private function updateComposerScripts(array &$json): bool 107 | { 108 | if (!isset($json['scripts'])) { 109 | $json['scripts'] = []; 110 | } 111 | 112 | $modified = false; 113 | $modified = $this->updateScript($json, 'post-update-cmd') || $modified; 114 | $modified = $this->updateScript($json, 'post-install-cmd') || $modified; 115 | 116 | return $modified; 117 | } 118 | 119 | private function updateScript(array &$json, string $scriptName): bool 120 | { 121 | if (!isset($json['scripts'][$scriptName])) { 122 | $json['scripts'][$scriptName] = [$this->postInstallCommand]; 123 | 124 | return true; 125 | } 126 | 127 | if (is_string($json['scripts'][$scriptName])) { 128 | if ($json['scripts'][$scriptName] !== $this->postInstallCommand) { 129 | $json['scripts'][$scriptName] = [$json['scripts'][$scriptName], $this->postInstallCommand]; 130 | 131 | return true; 132 | } 133 | } 134 | 135 | if (is_array($json['scripts'][$scriptName]) && !in_array($this->postInstallCommand, 136 | $json['scripts'][$scriptName])) { 137 | $json['scripts'][$scriptName][] = $this->postInstallCommand; 138 | 139 | return true; 140 | } 141 | 142 | return false; 143 | } 144 | 145 | private function runComposerDumpAutoload(): void 146 | { 147 | $this->info('[Composer] Running composer dump-autoload'); 148 | 149 | $process = Process::fromShellCommandline('composer dump-autoload', base_path()); 150 | $process->run(); 151 | 152 | if (!$process->isSuccessful()) { 153 | $this->error('[Composer] Failed to run composer dump-autoload'); 154 | $this->error($process->getErrorOutput()); 155 | } 156 | } 157 | 158 | private function restoreBackup(string $errorMessage): void 159 | { 160 | if (File::exists($this->backupPath)) { 161 | File::copy($this->backupPath, $this->composerPath); 162 | $this->error('An error occurred. The original file has been restored from backup.'); 163 | $this->error($errorMessage); 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Console/Actions/RunPackageInstall.php: -------------------------------------------------------------------------------- 1 | input = $input; 18 | $this->output = $output; 19 | } 20 | 21 | public function handle() 22 | { 23 | if (!File::exists(base_path('package.json'))) { 24 | $this->error('[Package] package.json not found in the project root!'); 25 | 26 | return 1; 27 | } 28 | 29 | // Determine if project uses Yarn or npm 30 | $packageManager = $this->determinePackageManager(); 31 | 32 | $this->info("[Package] Running {$packageManager} install..."); 33 | 34 | $process = new Process( 35 | [$packageManager, 'install'], base_path(), 36 | ); 37 | 38 | try { 39 | $process->run(function ($type, $buffer) { 40 | // Output progress in real-time 41 | if ($type === Process::ERR) { 42 | $this->error($buffer); 43 | } else { 44 | $this->line($buffer); 45 | } 46 | }); 47 | 48 | if (!$process->isSuccessful()) { 49 | $this->error("[Package] Failed to run {$packageManager} install"); 50 | $this->error($process->getErrorOutput()); 51 | 52 | return 1; 53 | } 54 | 55 | $this->info("[Package] Successfully ran {$packageManager} install"); 56 | 57 | return 0; 58 | } catch (\Exception $e) { 59 | $this->error("[Package] An error occurred while running {$packageManager} install"); 60 | $this->error($e->getMessage()); 61 | 62 | return 1; 63 | } 64 | } 65 | 66 | private function determinePackageManager(): string 67 | { 68 | // Check for yarn.lock first as it's more specific 69 | if (File::exists(base_path('yarn.lock'))) { 70 | return 'yarn'; 71 | } 72 | 73 | // Default to npm 74 | return 'npm'; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Console/Commands/Config.php: -------------------------------------------------------------------------------- 1 | 5 | * @link https://aaronfrancis.com 6 | * @link https://twitter.com/aarondfrancis 7 | */ 8 | 9 | namespace Fusion\Console\Commands; 10 | 11 | use Fusion\Fusion; 12 | use Illuminate\Console\Command; 13 | use Illuminate\Support\Str; 14 | 15 | class Config extends Command 16 | { 17 | protected $signature = 'fusion:config'; 18 | 19 | protected $description = 'Print the config for JavaScript.'; 20 | 21 | public function handle(): void 22 | { 23 | $config = array_replace_recursive(config('fusion'), [ 24 | // Add a few values that are useful in JavaScript but 25 | // not present in the config. 26 | 'paths' => [ 27 | 'base' => base_path(), 28 | 'config' => config_path('fusion.php'), 29 | 'database' => Fusion::storage('fusion.sqlite'), 30 | 'relativeJsRoot' => Str::after(config('fusion.paths.js'), base_path()), 31 | 'jsStorage' => Fusion::storage('JavaScript'), 32 | 'phpStorage' => Fusion::storage('PHP'), 33 | ], 34 | ]); 35 | 36 | $camel = []; 37 | 38 | foreach ($config as $key => $value) { 39 | if ($value instanceof \BackedEnum) { 40 | $value = $value->value; 41 | } 42 | 43 | // Convert the key to camel case to match JavaScript conventions. 44 | $camel[Str::camel($key)] = $value; 45 | } 46 | 47 | echo json_encode($camel); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Console/Commands/Conform.php: -------------------------------------------------------------------------------- 1 | 5 | * @link https://aaronfrancis.com 6 | * @link https://twitter.com/aarondfrancis 7 | */ 8 | 9 | namespace Fusion\Console\Commands; 10 | 11 | use Exception; 12 | use Fusion\Conformity\Conformer; 13 | use Fusion\Models\Component; 14 | use Illuminate\Console\Command; 15 | use Illuminate\Support\Facades\Http; 16 | use Throwable; 17 | 18 | class Conform extends Command 19 | { 20 | protected $signature = 'fusion:conform {src}'; 21 | 22 | protected $description = 'Conform a user file to the Fusion standard.'; 23 | 24 | protected Conformer $conformer; 25 | 26 | protected string $code; 27 | 28 | protected string $hash; 29 | 30 | protected string $destination; 31 | 32 | protected string $tmp; 33 | 34 | protected Component $component; 35 | 36 | public function handle() 37 | { 38 | $this->component = Component::where('src', $this->argument('src'))->firstOrFail(); 39 | 40 | // Vite writes this on its side. 41 | $this->destination = base_path($this->component->php_path); 42 | 43 | // But the user's code actually gets written here. 44 | $this->tmp = $this->destination . '.tmp'; 45 | 46 | try { 47 | $this->code = $this->getUserCode(); 48 | } catch (Exception $e) { 49 | // I'm not sure we actually care if the tmp file is gone. In all 50 | // likelihood that just means that it's already been conformed. 51 | return 0; 52 | } 53 | 54 | $this->code = str($this->code)->prepend('value(); 55 | 56 | $this->conformer = Conformer::make($this->code)->setFilename($this->destination); 57 | 58 | $result = $this->conformUserCode(); 59 | 60 | if (is_int($result)) { 61 | return $result; 62 | } 63 | 64 | $this->component->update([ 65 | 'php_class' => $this->conformer->getFullyQualifiedName() 66 | ]); 67 | 68 | file_put_contents($this->destination, $result); 69 | 70 | // We need to invalidate the opcache in the HTTP context, so we send a request. 71 | $invalidate = route('hmr.invalidate', [ 72 | 'file' => $this->destination 73 | ]); 74 | 75 | try { 76 | 77 | Http::get($invalidate); 78 | } catch (Throwable $e) { 79 | // 80 | } 81 | 82 | echo "Conformed $this->destination\n"; 83 | 84 | return 0; 85 | } 86 | 87 | protected function getUserCode(): string 88 | { 89 | if (!file_exists($this->tmp)) { 90 | throw new Exception("PHP tmp file not found for [{$this->component->src}], looked for [$this->tmp]."); 91 | } 92 | 93 | $code = file_get_contents($this->tmp); 94 | 95 | if ($code === false) { 96 | throw new Exception("Unable to read file [$this->tmp]."); 97 | } 98 | 99 | return $code; 100 | } 101 | 102 | protected function conformUserCode(): string|int 103 | { 104 | try { 105 | $result = $this->conformer->conform(); 106 | } catch (\PhpParser\Error $exception) { 107 | $exception->setStartLine( 108 | $exception->getStartLine() 109 | ); 110 | 111 | $formatted = [ 112 | 'message' => $exception->getMessage(), 113 | 'loc' => [ 114 | 'file' => $this->argument('src'), 115 | 'line' => $exception->getStartLine(), 116 | ] 117 | ]; 118 | 119 | if ($exception->hasColumnInfo()) { 120 | $formatted['loc']['column'] = $exception->getStartColumn($this->code); 121 | } 122 | 123 | // For Vite 124 | echo json_encode($formatted); 125 | 126 | // Proper(ish) code to alert Vite. 127 | return 65; 128 | } catch (Throwable $exception) { 129 | return 1; 130 | } finally { 131 | @unlink($this->tmp); 132 | } 133 | 134 | return $result; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Console/Commands/Install.php: -------------------------------------------------------------------------------- 1 | 5 | * @link https://aaronfrancis.com 6 | * @link https://twitter.com/aarondfrancis 7 | */ 8 | 9 | namespace Fusion\Console\Commands; 10 | 11 | use Fusion\Console\Actions\AddViteConfig; 12 | use Fusion\Console\Actions\AddVuePackage; 13 | use Fusion\Console\Actions\AddVuePlugin; 14 | use Fusion\Console\Actions\ModifyComposer; 15 | use Fusion\Console\Actions\RunPackageInstall; 16 | use Fusion\Fusion; 17 | use Illuminate\Console\Command; 18 | use Illuminate\Support\Facades\Artisan; 19 | use Illuminate\Support\Facades\File; 20 | 21 | class Install extends Command 22 | { 23 | protected $signature = 'fusion:install'; 24 | 25 | protected $description = 'Install the Fusion service provider'; 26 | 27 | public function handle() 28 | { 29 | $this->comment('Publishing Fusion configuration...'); 30 | $this->callSilent('vendor:publish', ['--tag' => 'fusion-config']); 31 | 32 | File::ensureDirectoryExists(Fusion::storage()); 33 | 34 | $this->action(AddVuePackage::class)->handle(); 35 | $this->action(AddViteConfig::class)->handle(); 36 | $this->action(AddVuePlugin::class)->handle(); 37 | $this->action(ModifyComposer::class)->handle(); 38 | $this->action(RunPackageInstall::class)->handle(); 39 | 40 | Artisan::call('fusion:post-install'); 41 | 42 | $this->info('Fusion installed successfully.'); 43 | } 44 | 45 | protected function action($class) 46 | { 47 | return app()->make($class, [ 48 | 'input' => $this->input, 49 | 'output' => $this->output, 50 | ]); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Console/Commands/Mirror.php: -------------------------------------------------------------------------------- 1 | createForHostVersion(); 38 | $prettyPrinter = new PrettyPrinter\Standard; 39 | 40 | try { 41 | // Get the trait content 42 | $code = file_get_contents((new ReflectionClass(IsProceduralPage::class))->getFileName()); 43 | $ast = $parser->parse($code); 44 | 45 | // Parse the code 46 | $ast = $parser->parse($code); 47 | 48 | // Extract namespace and build type map 49 | $this->buildTypeMap($ast); 50 | 51 | // Find all methods 52 | $nodeFinder = new NodeFinder; 53 | $methods = $nodeFinder->findInstanceOf($ast, ClassMethod::class); 54 | 55 | // Filter and transform methods 56 | $functions = []; 57 | foreach ($methods as $method) { 58 | if (!in_array($method->name->toString(), $this->mirroredMethods)) { 59 | continue; 60 | } 61 | 62 | // Create function signature 63 | $function = $this->methodToFunction($method); 64 | $functions[] = $prettyPrinter->prettyPrint([$function]) . "\n"; 65 | } 66 | 67 | // Create the output 68 | $output = "info('Successfully mirrored method signatures to functions.php'); 76 | 77 | } catch (Error $error) { 78 | $this->error("Parse error: {$error->getMessage()}"); 79 | 80 | return 1; 81 | } catch (\Exception $e) { 82 | $this->error("Error: {$e->getMessage()}"); 83 | 84 | return 1; 85 | } 86 | 87 | return 0; 88 | } 89 | 90 | protected function buildTypeMap(array $ast): void 91 | { 92 | $nodeFinder = new NodeFinder; 93 | 94 | // Find namespace 95 | $namespaceNodes = $nodeFinder->findInstanceOf($ast, Node\Stmt\Namespace_::class); 96 | if (count($namespaceNodes) > 0) { 97 | $this->namespace = $namespaceNodes[0]->name->toString(); 98 | } 99 | 100 | // Find use statements 101 | $useNodes = $nodeFinder->findInstanceOf($ast, Use_::class); 102 | 103 | foreach ($useNodes as $use) { 104 | foreach ($use->uses as $useUse) { 105 | $shortName = $useUse->name->getLast(); 106 | $fullName = $useUse->name->toString(); 107 | 108 | // If there's an alias, use it instead of the last part 109 | if ($useUse->alias) { 110 | $shortName = $useUse->alias->toString(); 111 | } 112 | 113 | $this->typeMap[$shortName] = $fullName; 114 | } 115 | } 116 | 117 | // Add some PHP built-in types that might not be in use statements 118 | $this->typeMap['Closure'] = 'Closure'; 119 | $this->typeMap['string'] = 'string'; 120 | $this->typeMap['int'] = 'int'; 121 | $this->typeMap['float'] = 'float'; 122 | $this->typeMap['bool'] = 'bool'; 123 | $this->typeMap['array'] = 'array'; 124 | $this->typeMap['callable'] = 'callable'; 125 | $this->typeMap['void'] = 'void'; 126 | $this->typeMap['mixed'] = 'mixed'; 127 | } 128 | 129 | protected function resolveType(Node\Name $type): Node\Name 130 | { 131 | $typeName = $type->toString(); 132 | 133 | // If it's already fully qualified, return as is 134 | if ($type instanceof FullyQualified) { 135 | return $type; 136 | } 137 | 138 | // Check if we have this type in our map 139 | if (isset($this->typeMap[$typeName])) { 140 | return new FullyQualified($this->typeMap[$typeName]); 141 | } 142 | 143 | // If not in map but we have a namespace, assume it's in current namespace 144 | if ($this->namespace) { 145 | return new FullyQualified($this->namespace . '\\' . $typeName); 146 | } 147 | 148 | return $type; 149 | } 150 | 151 | protected function methodToFunction(ClassMethod $method): Node\Stmt\Function_ 152 | { 153 | // Process parameters to ensure types are fully qualified 154 | $params = array_map(function ($param) { 155 | if ($param->type instanceof Node\Name) { 156 | $param->type = $this->resolveType($param->type); 157 | } 158 | 159 | return $param; 160 | }, $method->params); 161 | 162 | // Process return type if it exists 163 | $returnType = $method->returnType; 164 | if ($returnType instanceof Node\Name) { 165 | $returnType = $this->resolveType($returnType); 166 | } 167 | 168 | // Create docblock 169 | $docComment = new Comment\Doc(sprintf( 170 | "/**\n * @see %s::%s\n */", 171 | '\\' . IsProceduralPage::class, 172 | $method->name->toString() 173 | )); 174 | 175 | // Create implementation comment 176 | $implementationComment = new Node\Stmt\Nop; 177 | $implementationComment->setAttribute('comments', [ 178 | new Comment('// This function is implemented in IsProceduralPage') 179 | ]); 180 | 181 | // Create function with fully qualified types 182 | $function = new Node\Stmt\Function_( 183 | $method->name, 184 | [ 185 | 'params' => $params, 186 | 'returnType' => $returnType, 187 | 'stmts' => [$implementationComment], 188 | 'attrGroups' => [], 189 | 'byRef' => false, 190 | ] 191 | ); 192 | 193 | // Set the docblock as an attribute 194 | $function->setAttribute('comments', [$docComment]); 195 | 196 | return $function; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/Console/Commands/PostInstall.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Console\Commands; 8 | 9 | use Fusion\Fusion; 10 | use Illuminate\Console\Command; 11 | use Illuminate\Support\Facades\Artisan; 12 | use Illuminate\Support\Facades\File; 13 | 14 | class PostInstall extends Command 15 | { 16 | protected $signature = 'fusion:post-install'; 17 | 18 | protected $description = 'Migrate the internal DB'; 19 | 20 | public function handle(): void 21 | { 22 | $path = Fusion::storage('fusion.sqlite'); 23 | 24 | if (!file_exists($path)) { 25 | File::ensureDirectoryExists(dirname($path)); 26 | touch($path); 27 | } 28 | 29 | Artisan::call('migrate', [ 30 | '--database' => '__fusion', 31 | '--path' => __DIR__ . '/../../../database/migrations', 32 | '--realpath' => true, 33 | '--force' => true, 34 | ]); 35 | 36 | $this->info('Fusion database migrated.'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Console/Commands/Shim.php: -------------------------------------------------------------------------------- 1 | 5 | * @link https://aaronfrancis.com 6 | * @link https://twitter.com/aarondfrancis 7 | */ 8 | 9 | namespace Fusion\Console\Commands; 10 | 11 | use Exception; 12 | use Fusion\Fusion; 13 | use Fusion\Models\Component; 14 | use Fusion\Reflection\FusionPageReflection; 15 | use Illuminate\Console\Command; 16 | use Illuminate\Support\Facades\File; 17 | use Illuminate\Support\Str; 18 | 19 | class Shim extends Command 20 | { 21 | protected $signature = 'fusion:shim {src}'; 22 | 23 | protected $description = 'Create a JavaScript shim for a Fusion page.'; 24 | 25 | protected Component $component; 26 | 27 | public function handle() 28 | { 29 | $this->component = Component::where('src', $this->argument('src'))->firstOrFail(); 30 | 31 | $class = $this->component->php_class; 32 | 33 | if (!class_exists($class)) { 34 | throw new Exception("Class {$class} does not exist."); 35 | } 36 | 37 | $instance = new $class; 38 | $reflector = new FusionPageReflection($instance); 39 | 40 | // @TODO types 41 | 42 | $props = $reflector->hasProperty('discoveredProps') 43 | // Procedural 44 | ? $reflector->getProperty('discoveredProps')->getValue($instance) 45 | // Class 46 | : $reflector->propertiesForState()->pluck('name')->all(); 47 | 48 | $methods = $reflector->exposedActionMethods()->pluck('name')->all(); 49 | 50 | $duplicates = collect($methods)->merge($props)->duplicates(); 51 | 52 | if ($duplicates->isNotEmpty()) { 53 | throw new Exception( 54 | 'Properties and actions must have unique names. The following share a name: ' . $duplicates->implode(', ') 55 | ); 56 | } 57 | 58 | $state = collect($props) 59 | ->map(fn($prop) => json_encode($prop)) 60 | ->implode(', '); 61 | 62 | $fusion = collect($methods) 63 | ->filter(fn($method) => Str::startsWith($method, 'fusion')) 64 | ->map(fn($action) => json_encode($action)) 65 | ->implode(', '); 66 | 67 | $actions = collect($methods) 68 | ->reject(fn($method) => Str::startsWith($method, 'fusion')) 69 | ->map(fn($action) => json_encode($action)) 70 | ->implode(', '); 71 | 72 | $destination = str(base_path($this->component->src)) 73 | ->after(config('fusion.paths.js')) 74 | ->prepend(Fusion::storage('JavaScript')) 75 | ->replaceEnd('.vue', '.js') 76 | ->value(); 77 | 78 | $js = <<component->update([ 116 | 'shim_path' => Str::chopStart($destination, base_path()) 117 | ]); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Enums/Frontend.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Enums; 8 | 9 | enum Frontend: string 10 | { 11 | case Vue = 'vue'; 12 | } 13 | -------------------------------------------------------------------------------- /src/Fusion.php: -------------------------------------------------------------------------------- 1 | 5 | * @link https://aaronfrancis.com 6 | * @link https://twitter.com/aarondfrancis 7 | */ 8 | 9 | namespace Fusion; 10 | 11 | use Illuminate\Support\Facades\Facade; 12 | 13 | class Fusion extends Facade 14 | { 15 | protected static function getFacadeAccessor(): string 16 | { 17 | return FusionManager::class; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/FusionManager.php: -------------------------------------------------------------------------------- 1 | 5 | * @link https://aaronfrancis.com 6 | * @link https://twitter.com/aarondfrancis 7 | */ 8 | 9 | namespace Fusion; 10 | 11 | use Closure; 12 | use Fusion\Http\Request\RequestHelper; 13 | use Fusion\Http\Response\PendingResponse; 14 | use Fusion\Routing\Registrar; 15 | use Illuminate\Foundation\Application; 16 | use Illuminate\Support\Str; 17 | use InvalidArgumentException; 18 | use Symfony\Component\Finder\Finder; 19 | use Symfony\Component\Finder\SplFileInfo; 20 | 21 | class FusionManager 22 | { 23 | public function __construct(public Application $app) 24 | { 25 | // 26 | } 27 | 28 | public function storage($path = null): string 29 | { 30 | if ($path) { 31 | $path = Str::ltrim($path, DIRECTORY_SEPARATOR); 32 | } 33 | 34 | return Str::finish(config('fusion.paths.storage'), DIRECTORY_SEPARATOR) . $path; 35 | } 36 | 37 | public function request(): RequestHelper 38 | { 39 | return $this->app->make(RequestHelper::class); 40 | } 41 | 42 | public function response(): PendingResponse 43 | { 44 | return $this->app->make(PendingResponse::class); 45 | } 46 | 47 | public function page(string $uri, string $component) 48 | { 49 | return (new Registrar)->page($uri, $component); 50 | } 51 | 52 | public function pages(string $root = '/', string|Closure $directory = ''): void 53 | { 54 | $pages = config('fusion.paths.pages'); 55 | 56 | if (is_string($directory) && is_dir($directory)) { 57 | if (!Str::startsWith($directory, $pages)) { 58 | throw new InvalidArgumentException( 59 | "The directory passed to Fusion::pages() must be a subdirectory of your Pages directory: [$pages]." 60 | ); 61 | } 62 | } 63 | 64 | if (is_string($directory)) { 65 | $directory = str($directory)->start(DIRECTORY_SEPARATOR)->prepend($pages)->value(); 66 | $directory = function () use ($directory) { 67 | return Finder::create()->in($directory)->name('*.vue'); 68 | }; 69 | } 70 | 71 | $finder = call_user_func($directory); 72 | 73 | if (!$finder instanceof Finder) { 74 | throw new InvalidArgumentException( 75 | 'Fusion::pages() $directory closure must return an instance of Finder.' 76 | ); 77 | } 78 | 79 | $files = collect($finder->files()->getIterator()) 80 | ->map(fn(SplFileInfo $file) => $file->getRelativePathname()) 81 | ->toArray(); 82 | 83 | (new Registrar)->pages($root, $files); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/FusionPage.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion; 8 | 9 | use Fusion\Attributes\ServerOnly; 10 | use Fusion\Concerns\BootsTraits; 11 | use Fusion\Concerns\ComputesState; 12 | use Fusion\Concerns\HandlesExposedActions; 13 | use Fusion\Concerns\InteractsWithQueryStrings; 14 | use Fusion\Reflection\FusionPageReflection; 15 | use Illuminate\Http\JsonResponse; 16 | use Illuminate\Routing\ControllerDispatcher; 17 | use Inertia\Inertia; 18 | use Inertia\Response; 19 | use ReflectionParameter; 20 | use ReflectionProperty; 21 | 22 | class FusionPage 23 | { 24 | use BootsTraits, ComputesState, HandlesExposedActions, InteractsWithQueryStrings; 25 | 26 | #[ServerOnly] 27 | public FusionPageReflection $reflector; 28 | 29 | public function __construct() 30 | { 31 | $this->reflector = new FusionPageReflection($this); 32 | 33 | $this->boot(); 34 | $this->bootTraits(); 35 | } 36 | 37 | public function boot() 38 | { 39 | // 40 | } 41 | 42 | public function isProcedural(): bool 43 | { 44 | return property_exists($this, 'isProcedural'); 45 | } 46 | 47 | public function initializePageState(): void 48 | { 49 | foreach ($this->reflector->propertiesInitFromState() as $property) { 50 | /** @var ReflectionProperty $property */ 51 | if (Fusion::request()->state->has($property->name)) { 52 | $this->{$property->name} = Fusion::request()->state->get($property->name); 53 | } 54 | } 55 | } 56 | 57 | public function handlePageRequest(): JsonResponse|Response 58 | { 59 | Fusion::response()->mergeState($this->state()); 60 | 61 | if (Fusion::request()->isFusionHmr() || Fusion::request()->isFusionAction()) { 62 | return response()->json(Fusion::response()->forTransport()); 63 | } 64 | 65 | return Inertia::render( 66 | Fusion::request()->component(), Fusion::response()->forTransport() 67 | ); 68 | } 69 | 70 | public function fusionSync(): JsonResponse 71 | { 72 | // Pretty simple, just send all the state back out as JSON. 73 | return $this->handlePageRequest(); 74 | } 75 | 76 | /** 77 | * This is a bit of magic from Laravel. Whenever a controller action is about 78 | * to be run, you can intercept the call if there's a callAction method. 79 | * We intercept it so we can muck about with the parameters a bit. 80 | * 81 | * @see ControllerDispatcher::dispatch() 82 | */ 83 | public function callAction($method, $parameters) 84 | { 85 | // Automount has no signature and requires named params, so we're done. 86 | if ($method === 'automount') { 87 | return $this->automount($parameters); 88 | } 89 | 90 | $dependencies = $this->reflector->getMethod($method)->getParameters(); 91 | 92 | $named = []; 93 | $positional = []; 94 | 95 | // Split the given parameters into named and positional so 96 | // that we can load them into their correct positions. 97 | foreach ($parameters as $key => $parameter) { 98 | if (is_int($key)) { 99 | $positional[] = $parameter; 100 | } else { 101 | $named[$key] = $parameter; 102 | } 103 | } 104 | 105 | $dependencies = collect($dependencies) 106 | ->mapWithKeys(function (ReflectionParameter $dependency) use ($named, &$positional) { 107 | $name = $dependency->getName(); 108 | 109 | if (array_key_exists($name, $named)) { 110 | $value = $named[$name]; 111 | } else { 112 | // Positional arguments just go in order. 113 | $value = count($positional) ? array_shift($positional) : null; 114 | } 115 | 116 | return [$name => $value]; 117 | }); 118 | 119 | return $this->$method(...$dependencies->all()); 120 | } 121 | 122 | protected function automount($bound): void 123 | { 124 | foreach ($bound as $key => $value) { 125 | if (property_exists($this, $key)) { 126 | $this->{$key} = $value; 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Http/Controllers/FusionController.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Controllers; 8 | 9 | use Fusion\Fusion; 10 | use Fusion\Http\Middleware\MergeStateIntoActionResponse; 11 | use Fusion\Http\Middleware\RouteBindingForAction; 12 | use Fusion\Http\Middleware\RouteBindingForPage; 13 | 14 | class FusionController 15 | { 16 | public function handle() 17 | { 18 | Fusion::request()->init(); 19 | 20 | return Fusion::request()->runSyntheticStack([ 21 | [ 22 | 'handler' => 'initializePageState', 23 | ], [ 24 | 'handler' => 'initializeFromQueryString', 25 | ], [ 26 | 'handler' => $this->methodForInstantiation(), 27 | 'middleware' => [RouteBindingForPage::class], 28 | ], 29 | $this->methodForHandling() 30 | ]); 31 | } 32 | 33 | protected function methodForInstantiation() 34 | { 35 | if (Fusion::request()->page->isProcedural()) { 36 | return 'runProceduralCode'; 37 | } 38 | 39 | return method_exists(Fusion::request()->page, 'mount') ? 'mount' : 'automount'; 40 | } 41 | 42 | protected function methodForHandling(): array 43 | { 44 | if (Fusion::request()->isFusionPage()) { 45 | return [ 46 | 'handler' => 'handlePageRequest' 47 | ]; 48 | } 49 | 50 | $requested = Fusion::request()->base->header('X-Fusion-Action-Handler'); 51 | 52 | $allowed = Fusion::request()->page->reflector->exposedActionMethods()->pluck('name'); 53 | 54 | if ($allowed->contains($requested)) { 55 | return [ 56 | 'handler' => $requested, 57 | 'middleware' => [ 58 | RouteBindingForAction::class, 59 | MergeStateIntoActionResponse::class 60 | ], 61 | ]; 62 | } 63 | 64 | abort(404); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Http/Controllers/HmrController.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Controllers; 8 | 9 | use Illuminate\Http\Request; 10 | 11 | class HmrController 12 | { 13 | public function __construct() 14 | { 15 | if (app()->environment('production')) { 16 | abort(404); 17 | } 18 | } 19 | 20 | public function invalidate(Request $request): void 21 | { 22 | if (!function_exists('opcache_get_status') || !function_exists('opcache_invalidate')) { 23 | return; 24 | } 25 | 26 | $status = opcache_get_status(false); 27 | 28 | if (isset($status['opcache_enabled']) && $status['opcache_enabled']) { 29 | if ($file = $request->query('file')) { 30 | opcache_invalidate($file, force: true); 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Http/Middleware/MergeStateIntoActionResponse.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Middleware; 8 | 9 | use Closure; 10 | use Fusion\Fusion; 11 | use Illuminate\Http\Request; 12 | 13 | class MergeStateIntoActionResponse 14 | { 15 | public function handle(Request $request, Closure $next) 16 | { 17 | $response = $next($request); 18 | 19 | if (Fusion::response()->hasPendingState()) { 20 | return $response; 21 | } 22 | 23 | return $response; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Http/Middleware/RouteBinding.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Middleware; 8 | 9 | use Closure; 10 | use Fusion\Routing\SubstituteBindings; 11 | use Illuminate\Http\Request; 12 | 13 | abstract class RouteBinding 14 | { 15 | public function handle(Request $request, Closure $next) 16 | { 17 | $bindings = $this->getBindings(); 18 | 19 | if ($bindings) { 20 | $route = $request->route(); 21 | $params = (new SubstituteBindings($bindings))->resolve($route->parameters()); 22 | foreach ($params as $key => $param) { 23 | $route->setParameter($key, $param); 24 | } 25 | } 26 | 27 | return $next($request); 28 | } 29 | 30 | abstract public function getBindings(): array; 31 | } 32 | -------------------------------------------------------------------------------- /src/Http/Middleware/RouteBindingForAction.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Middleware; 8 | 9 | use Fusion\Fusion; 10 | use Illuminate\Http\Request; 11 | 12 | class RouteBindingForAction extends RouteBinding 13 | { 14 | public function getBindings(): array 15 | { 16 | $route = Fusion::request()->base->route(); 17 | $page = Fusion::request()->page; 18 | 19 | $bindings = $page->reflector->bindingsForAction($route->getActionMethod()); 20 | 21 | // Reset the parameters, as these are likely bound to the page request 22 | // and we don't want them to collide with the action request. 23 | $route->parameters = []; 24 | 25 | // We manually set the parameters because we don't have them defined in the URL. 26 | // Typically these are parsed via regex. We check to see what parameters the 27 | // function is requesting and, if they're present, add them to the route. 28 | foreach ($bindings as $name => $binding) { 29 | if (Fusion::request()->args->has($name)) { 30 | $route->setParameter($name, Fusion::request()->args->get($name)); 31 | } 32 | } 33 | 34 | return $bindings; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Http/Middleware/RouteBindingForPage.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Middleware; 8 | 9 | use Fusion\Fusion; 10 | 11 | class RouteBindingForPage extends RouteBinding 12 | { 13 | public function getBindings(): array 14 | { 15 | return Fusion::request()->page->reflector->pageLevelRouteBindings(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Request/RequestHelper.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Request; 8 | 9 | use Fusion\FusionPage; 10 | use Fusion\Http\Response\PendingResponse; 11 | use Fusion\Support\Fluent; 12 | use Illuminate\Foundation\Application; 13 | use Illuminate\Http\Request; 14 | use Illuminate\Support\Str; 15 | 16 | class RequestHelper 17 | { 18 | use RunsSyntheticActions; 19 | 20 | public Fluent $args; 21 | 22 | public Fluent $state; 23 | 24 | public Fluent $meta; 25 | 26 | public FusionPage $page; 27 | 28 | protected bool $hasBeenInit = false; 29 | 30 | public function __construct(public Application $app, public Request $base) 31 | { 32 | // 33 | } 34 | 35 | public function init(): void 36 | { 37 | if ($this->hasBeenInit) { 38 | return; 39 | } 40 | 41 | $this->hasBeenInit = true; 42 | 43 | $this->processVariadics(); 44 | $this->modifyInboundJson(); 45 | $this->instantiatePendingResponse(); 46 | $this->setHeaders(); 47 | $this->forgetStashedData(); 48 | 49 | // This should always come last, after all setup is done. 50 | $this->instantiateFusionPage(); 51 | } 52 | 53 | public function isFusion(): bool 54 | { 55 | return $this->base->isFusion(); 56 | } 57 | 58 | public function isFusionPage(): bool 59 | { 60 | return $this->base->isFusionPage(); 61 | } 62 | 63 | public function isFusionHmr(): bool 64 | { 65 | return $this->base->isFusionHmr(); 66 | } 67 | 68 | public function isFusionAction(): bool 69 | { 70 | return $this->base->isFusionAction(); 71 | } 72 | 73 | public function component() 74 | { 75 | return $this->base->route()->defaults['__component']; 76 | } 77 | 78 | public function instantiateFusionPage(): void 79 | { 80 | // This is where we actually get the FusionPage class. It should be impossible 81 | // to change the param unless the developer defines a __class param in their 82 | // route. It's still safer to pull from the defaults, so that's what we do. 83 | $class = $this->base->route()->defaults['__class']; 84 | 85 | // When pages do not have a PHP block they don't get a generated PHP class, 86 | // so we just instantiate the base FusionPage class. 87 | if (is_null($class)) { 88 | $class = FusionPage::class; 89 | } 90 | 91 | // get_class() always strips the initial backslash so, to make sure 92 | // we can destroy it properly, we do the same when registering. 93 | $class = Str::chopStart($class, '\\'); 94 | 95 | $this->app->forgetInstance($class); 96 | $this->app->singleton($class); 97 | 98 | $this->page = app($class); 99 | } 100 | 101 | protected function processVariadics(): void 102 | { 103 | $route = $this->base->route(); 104 | 105 | // We stashed a bit of info in the defaults to denote which parameter 106 | // is variadic. Just like we do with the classes and components. 107 | if (!isset($route->defaults['__variadic'])) { 108 | return; 109 | } 110 | 111 | // This is the name of the variadic param. 112 | $variadic = $route->defaults['__variadic']; 113 | 114 | if (!$route->hasParameter($variadic)) { 115 | return; 116 | } 117 | 118 | $value = $route->parameter($variadic); 119 | 120 | // Overwrite the string version with an exploded version. 121 | $route->setParameter($variadic, is_string($value) ? explode('/', $value) : $value); 122 | } 123 | 124 | protected function modifyInboundJson(): void 125 | { 126 | $this->args = new Fluent($this->base->json('fusion.args') ?? []); 127 | $this->meta = new Fluent($this->base->json('fusion.meta') ?? []); 128 | $this->state = new Fluent($this->base->json('fusion.state') ?? []); 129 | 130 | // Pull the 'fusion' key out of the JSON bag so that the FusionPage 131 | // method only sees input relevant to the actual function, not 132 | // all the meta used to find the class, method, etc. 133 | $this->base->json()->remove('fusion'); 134 | } 135 | 136 | protected function instantiatePendingResponse(): void 137 | { 138 | $this->app->forgetInstance(PendingResponse::class); 139 | $this->app->singleton(PendingResponse::class); 140 | } 141 | 142 | protected function setHeaders(): void 143 | { 144 | $this->base->isFusion(set: true); 145 | 146 | // This is set by the frontend. So if it's there we're done. 147 | if ($this->base->isFusionAction()) { 148 | return; 149 | } 150 | 151 | $this->base->isFusionPage(set: true); 152 | } 153 | 154 | protected function forgetStashedData(): void 155 | { 156 | // We set a default when we register the route, as a way to link the route to 157 | // the page. Since __class the parameter is always blank, it gets set to 158 | // the default. We remove it so that it doesn't get relied on at all. 159 | $this->base->route()->forgetParameter('__class'); 160 | $this->base->route()->forgetParameter('__component'); 161 | $this->base->route()->forgetParameter('__variadic'); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Http/Request/RunsSyntheticActions.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Request; 8 | 9 | use Fusion\Attributes\Middleware; 10 | use Fusion\Fusion; 11 | use Fusion\FusionPage; 12 | use Illuminate\Routing\Pipeline; 13 | use Illuminate\Routing\Route; 14 | use Illuminate\Routing\Router; 15 | use Illuminate\Support\Arr; 16 | use ReflectionAttribute; 17 | use ReflectionMethod; 18 | 19 | /** 20 | * @mixin RequestHelper 21 | */ 22 | trait RunsSyntheticActions 23 | { 24 | public function runSyntheticStack(array $stack) 25 | { 26 | $stack = $this->addUserMethodsToStack($stack); 27 | 28 | foreach ($stack as $frame) { 29 | $response = $this->runSyntheticAction( 30 | $frame['handler'], Arr::get($frame, 'middleware', []) 31 | ); 32 | 33 | // Could be a route-model binding error, ValidatesWhenResolved 34 | // exception, redirect, etc. 35 | if (!is_null($response)) { 36 | return $response; 37 | } 38 | } 39 | 40 | // Return the last response from the stack. 41 | return $response ?? null; 42 | } 43 | 44 | public function addUserMethodsToStack($stack): array 45 | { 46 | $restacked = []; 47 | 48 | foreach ($stack as $frame) { 49 | $method = $frame['handler']; 50 | 51 | if ($method === 'automount') { 52 | $method = 'mount'; 53 | } 54 | 55 | if (is_string($method) && method_exists($this->page, 'before' . ucfirst($method))) { 56 | $restacked[] = [ 57 | 'handler' => 'before' . ucfirst($method), 58 | ]; 59 | } 60 | 61 | $restacked[] = $frame; 62 | 63 | if (is_string($method) && method_exists($this->page, 'after' . ucfirst($method))) { 64 | $restacked[] = [ 65 | 'handler' => 'after' . ucfirst($method), 66 | ]; 67 | } 68 | } 69 | 70 | return $restacked; 71 | } 72 | 73 | public function runSyntheticAction($method, $middleware = []) 74 | { 75 | /** @var Route $synthetic */ 76 | $synthetic = tap(clone $this->base->route(), function (Route $synthetic) use ($method) { 77 | // Copy the params over. 78 | $synthetic->parameters = $this->base->route()->parameters; 79 | 80 | // Set it to run the method on the FusionPage instead of the `handle` 81 | // method in this controller, which is where it's currently bound. 82 | $synthetic->flushController(); 83 | $synthetic->action = []; 84 | 85 | if (is_string($method)) { 86 | $synthetic->uses([get_class($this->page), $method]); 87 | } else { 88 | $synthetic->uses($method); 89 | } 90 | }); 91 | 92 | // Stash the real resolver for restoration further down. 93 | $original = $this->base->getRouteResolver(); 94 | 95 | // Force it to resolve to our fake route. 96 | $this->base->setRouteResolver(fn() => $synthetic); 97 | 98 | if (is_string($method)) { 99 | $middleware = $this->makeMiddleware(new ReflectionMethod($this->page, $method), $middleware); 100 | } 101 | 102 | return (new Pipeline($this->app)) 103 | ->send($this->base) 104 | ->through($middleware) 105 | ->then(function () use ($synthetic, $original) { 106 | $this->base->setRouteResolver($original); 107 | 108 | return $synthetic->run(); 109 | }); 110 | } 111 | 112 | public function makeMiddleware(ReflectionMethod $method, ?array $existing = []): array 113 | { 114 | return collect($method->getAttributes(Middleware::class)) 115 | // Check for any middleware that might be annotated on the action. 116 | ->flatMap(fn(ReflectionAttribute $attribute) => $attribute->newInstance()->middleware) 117 | // Put the Fusion-defined middleware first, before the user-defined set. 118 | ->unshift(...$existing) 119 | // This will make sure they're all unique. 120 | ->pipe(fn($middleware) => app(Router::class)->resolveMiddleware($middleware->all())); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Http/Response/Actions/ApplyServerState.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Response\Actions; 8 | 9 | class ApplyServerState extends ResponseAction 10 | { 11 | public string $handler = 'applyServerState'; 12 | } 13 | -------------------------------------------------------------------------------- /src/Http/Response/Actions/Log.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Response\Actions; 8 | 9 | class Log extends ResponseAction 10 | { 11 | public string $handler = 'log'; 12 | 13 | public function __construct(public string $message = '') 14 | { 15 | // 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Response/Actions/LogStack.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Response\Actions; 8 | 9 | class LogStack extends ResponseAction 10 | { 11 | public string $handler = 'logStack'; 12 | } 13 | -------------------------------------------------------------------------------- /src/Http/Response/Actions/ResponseAction.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Response\Actions; 8 | 9 | use Fusion\Reflection\ReflectionClass; 10 | use Illuminate\Contracts\Support\Jsonable; 11 | use JsonSerializable; 12 | use ReflectionProperty; 13 | 14 | class ResponseAction implements Jsonable, JsonSerializable 15 | { 16 | public int $priority = 50; 17 | 18 | public function jsonSerialize(): mixed 19 | { 20 | /** @noinspection PhpUnhandledExceptionInspection */ 21 | $properties = (new ReflectionClass($this))->getProperties(ReflectionProperty::IS_PUBLIC); 22 | 23 | return collect($properties) 24 | ->mapWithKeys(function (ReflectionProperty $property) { 25 | return [$property->getName() => $property->getValue($this)]; 26 | }) 27 | ->put('_handler', static::class) 28 | ->toArray(); 29 | } 30 | 31 | public function toJson($options = 0): false|string 32 | { 33 | return json_encode($this->jsonSerialize(), $options); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Http/Response/Actions/SyncQueryString.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Response\Actions; 8 | 9 | class SyncQueryString extends ResponseAction 10 | { 11 | public string $handler = 'syncQueryString'; 12 | 13 | public function __construct(public string $property, public string $query) 14 | { 15 | // 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Response/PendingResponse.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Http\Response; 8 | 9 | use Exception; 10 | use Fusion\Fusion; 11 | use Fusion\Http\Response\Actions\ApplyServerState; 12 | use Fusion\Http\Response\Actions\Log; 13 | use Fusion\Http\Response\Actions\LogStack; 14 | use Fusion\Http\Response\Actions\ResponseAction; 15 | use Fusion\Support\Fluent; 16 | 17 | class PendingResponse 18 | { 19 | protected Fluent $bag; 20 | 21 | public static array $stack = [ 22 | // [00, LogStack::class], 23 | // [20, SignFunctionParams::class], 24 | // [20, Rehydrate::class], 25 | [40, ApplyServerState::class], 26 | // [50, Log::class], 27 | ]; 28 | 29 | public function __construct() 30 | { 31 | $this->bag = new Fluent([ 32 | 'meta' => [ 33 | // Fusion metadata 34 | ], 35 | 'state' => [ 36 | // State that will be applied to the frontend. Note that Fusion 37 | // handles the crucial state itself, so developers probably 38 | // won't need to add anything here. 39 | ], 40 | 'actions' => [ 41 | // A stack of actions to be run on the frontend after receiving a 42 | // Fusion response. Each one is tied to a JavaScript handler. 43 | ], 44 | ]); 45 | 46 | foreach (static::$stack as [$priority, $action]) { 47 | $this->addAction($action, $priority); 48 | } 49 | } 50 | 51 | public function hasPendingState(): bool 52 | { 53 | return !empty($this->bag->get('state', [])); 54 | } 55 | 56 | public function mergeState(array $state): static 57 | { 58 | $this->bag->merge('state', $state); 59 | 60 | return $this; 61 | } 62 | 63 | /** 64 | * @throws Exception 65 | */ 66 | public function addAction(string|ResponseAction $action, $priority = 50): static 67 | { 68 | if (is_string($action)) { 69 | if (is_a($action, ResponseAction::class, true)) { 70 | $action = new $action; 71 | } else { 72 | throw new Exception('Action must be an instance of ResponseAction'); 73 | } 74 | } 75 | 76 | $action->priority = $priority; 77 | 78 | $this->bag->push('actions', $action); 79 | 80 | return $this; 81 | } 82 | 83 | public function forTransport(): array 84 | { 85 | return [ 86 | // Everything gets nested under the `fusion` 87 | // key on the way in and the way out. 88 | 'fusion' => $this->bag->toArray() 89 | ]; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Models/Component.php: -------------------------------------------------------------------------------- 1 | 5 | * @link https://aaronfrancis.com 6 | * @link https://twitter.com/aarondfrancis 7 | */ 8 | 9 | namespace Fusion\Providers; 10 | 11 | use Fusion\Console\Commands; 12 | use Fusion\Enums\Frontend; 13 | use Fusion\Fusion; 14 | use Fusion\FusionManager; 15 | use Fusion\Http\Controllers\HmrController; 16 | use Fusion\Http\Request\RequestHelper; 17 | use Illuminate\Foundation\Application; 18 | use Illuminate\Http\Request; 19 | use Illuminate\Routing\Router; 20 | use Illuminate\Support\Arr; 21 | use Illuminate\Support\Facades\Config as ConfigFacade; 22 | use Illuminate\Support\Facades\Route; 23 | use Illuminate\Support\ServiceProvider; 24 | use Illuminate\Support\Str; 25 | 26 | class FusionServiceProvider extends ServiceProvider 27 | { 28 | public function register() 29 | { 30 | $this->app->singleton(FusionManager::class); 31 | 32 | $this->mergeConfigFrom(__DIR__ . '/../../config/fusion.php', 'fusion'); 33 | } 34 | 35 | public function boot() 36 | { 37 | $this->registerRouting(); 38 | $this->registerMacros(); 39 | $this->registerEvents(); 40 | $this->registerBindings(); 41 | 42 | if ($this->app->runningInConsole()) { 43 | $this->registerCommands(); 44 | $this->registerBlade(); 45 | $this->publishFiles(); 46 | } 47 | } 48 | 49 | protected function registerMacros(): void 50 | { 51 | $macro = function ($header) { 52 | return function (?bool $set = null) use ($header) { 53 | /** @var Request $this */ 54 | if (!is_null($set)) { 55 | $this->headers->set($header, json_encode($set)); 56 | } 57 | 58 | return $this->headers->get($header) === 'true'; 59 | }; 60 | }; 61 | 62 | Request::macro('isFusion', $macro('X-Fusion-Request')); 63 | Request::macro('isFusionPage', $macro('X-Fusion-Page-Request')); 64 | Request::macro('isFusionHmr', $macro('X-Fusion-Hmr-Request')); 65 | Request::macro('isFusionAction', $macro('X-Fusion-Action-Request')); 66 | 67 | Router::macro('hasExplicitBinding', function ($name) { 68 | return array_key_exists($name, $this->binders); 69 | }); 70 | 71 | Router::macro('performExplicitBinding', function ($key, $value) { 72 | return call_user_func($this->binders[$key], $value, null); 73 | }); 74 | } 75 | 76 | protected function registerRouting(): void 77 | { 78 | if (!app()->environment('production')) { 79 | Route::get('/_fusion/hmr/invalidate', [HmrController::class, 'invalidate'])->name('hmr.invalidate'); 80 | } 81 | } 82 | 83 | protected function registerCommands(): void 84 | { 85 | $this->commands([ 86 | Commands\Install::class, 87 | Commands\Conform::class, 88 | Commands\Config::class, 89 | Commands\Shim::class, 90 | Commands\PostInstall::class, 91 | Commands\Mirror::class, 92 | ]); 93 | } 94 | 95 | protected function registerBindings(): void 96 | { 97 | $this->app->singleton(RequestHelper::class, function (Application $app) { 98 | $request = $app->make(Request::class); 99 | 100 | return new RequestHelper($app, $request); 101 | }); 102 | 103 | ConfigFacade::set('database.connections.__fusion', [ 104 | 'name' => '__fusion', 105 | 'driver' => 'sqlite', 106 | 'database' => Fusion::storage('fusion.sqlite'), 107 | 'foreign_key_constraints' => true, 108 | // Since we're super low concurrency and the entire DB can be rebuilt on command, we opt for the convenience of a single file at the expense of a higher risk of corruption on crash. 109 | 'journal_mode' => 'OFF', 110 | ]); 111 | } 112 | 113 | protected function registerEvents(): void 114 | { 115 | // 116 | } 117 | 118 | protected function registerBlade(): void 119 | { 120 | $frontend = Arr::get($this->app->config['fusion'], 'frontend', Frontend::Vue); 121 | 122 | if ($frontend instanceof Frontend) { 123 | $frontend = $frontend->value; 124 | } 125 | 126 | $this->loadViewsFrom(__DIR__ . '/../Blade/' . ucfirst(Str::camel($frontend)), 'fusion'); 127 | } 128 | 129 | protected function publishFiles(): void 130 | { 131 | $this->publishes([ 132 | __DIR__ . '/../../config/fusion.php' => config_path('fusion.php'), 133 | ], 'fusion-config'); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Reflection/FusionPageReflection.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Reflection; 8 | 9 | use App\Http\Actions\Computed; 10 | use Fusion\Attributes\IsReadOnly; 11 | use Fusion\Attributes\ServerOnly; 12 | use Fusion\Attributes\SyncQueryString; 13 | use Fusion\Fusion; 14 | use Fusion\FusionPage; 15 | use Illuminate\Support\Str; 16 | use ReflectionMethod; 17 | use ReflectionProperty; 18 | 19 | class FusionPageReflection extends ReflectionClass 20 | { 21 | public FusionPage $instance; 22 | 23 | public function __construct(FusionPage $instance) 24 | { 25 | $this->instance = $instance; 26 | 27 | parent::__construct($instance); 28 | } 29 | 30 | public function pageLevelRouteBindings(): array 31 | { 32 | return $this->hasMethod('mount') ? $this->bindingsForMountFunction() : $this->bindingsForAutomountFunction(); 33 | } 34 | 35 | public function bindingsForAction($name): array 36 | { 37 | $bindings = []; 38 | 39 | $reflection = $this->getMethod($name); 40 | 41 | foreach ($reflection->getParameters() as $parameter) { 42 | $class = $parameter->getType()?->getName(); 43 | $bindings[$parameter->getName()] = [ 44 | // @TODO attributes to influence key and withTrashed? 45 | 'class' => $class 46 | ]; 47 | } 48 | 49 | return $bindings; 50 | } 51 | 52 | protected function bindingsForMountFunction(): array 53 | { 54 | $method = $this->getMethod('mount'); 55 | 56 | $bindings = []; 57 | foreach ($method->getParameters() as $param) { 58 | // @TODO make this a DTO 59 | // 'podcast' => [ 60 | // 'class' => \App\Models\Podcast::class, 61 | // 'key' => 'slug', 62 | // 'withTrashed' => true, 63 | // ] 64 | 65 | $class = $param->getType()?->getName(); 66 | $bindings[$param->getName()] = [ 67 | // @TODO attributes to influence key and withTrashed? 68 | 'class' => $class 69 | ]; 70 | } 71 | 72 | return $bindings; 73 | } 74 | 75 | protected function bindingsForAutomountFunction(): array 76 | { 77 | $bindings = []; 78 | 79 | $parameters = $this->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED); 80 | 81 | foreach ($parameters as $parameter) { 82 | if (!Fusion::request()->base->route()->hasParameter($parameter->getName())) { 83 | continue; 84 | } 85 | 86 | $class = $parameter->getType()?->getName(); 87 | $bindings[$parameter->getName()] = [ 88 | // @TODO attributes to influence key and withTrashed? 89 | 'class' => $class 90 | ]; 91 | } 92 | 93 | return $bindings; 94 | } 95 | 96 | public function exposedActionMethods(): ReflectionCollection 97 | { 98 | return $this->collectMethods() 99 | ->filterAnyModifiers([ 100 | ReflectionProperty::IS_PUBLIC, 101 | ]) 102 | ->rejectAnyAnnotations([ 103 | ServerOnly::class, 104 | ]) 105 | ->filter(function (ReflectionMethod $method) { 106 | // These are public, but written by Fusion, so we exclude them. 107 | if (in_array($method->name, ['mount', 'runProceduralCode'])) { 108 | return false; 109 | } 110 | 111 | // Any public methods written by the developer. 112 | if ($method->class !== FusionPage::class) { 113 | return true; 114 | } 115 | 116 | // Or any methods prefixed with `fusion`, as these are helpers 117 | // that end up as `fusion.[xxx]` on the frontend. 118 | return Str::startsWith($method->name, 'fusion'); 119 | }) 120 | ->values(); 121 | } 122 | 123 | public function computedPropertyMethods(): ReflectionCollection 124 | { 125 | return $this->collectMethods() 126 | ->filterAllModifiers([ 127 | ReflectionMethod::IS_PUBLIC, 128 | ]) 129 | ->pipe(function (ReflectionCollection $collection) { 130 | return $collection->filter(function (ReflectionMethod $method) { 131 | // It matches our getFooProp convention or is explicitly tagged. 132 | return 133 | Str::isMatch('/^get(.*)Prop$/', $method->getName()) 134 | || Reflector::isAnnotatedByAny($method, Computed::class); 135 | }); 136 | }); 137 | } 138 | 139 | public function propertiesForState(): ReflectionCollection 140 | { 141 | return $this->collectProperties() 142 | ->filterAllModifiers(ReflectionProperty::IS_PUBLIC) 143 | ->rejectAnyModifiers(ReflectionProperty::IS_STATIC) 144 | ->rejectAnyAnnotations(ServerOnly::class); 145 | } 146 | 147 | public function propertiesInitFromState(): ReflectionCollection 148 | { 149 | return $this->propertiesForState() 150 | ->rejectAnyAnnotations(IsReadOnly::class); 151 | } 152 | 153 | public function propertiesInitFromQueryString(): ReflectionCollection 154 | { 155 | return $this->collectProperties() 156 | ->filterAnyAnnotations([ 157 | SyncQueryString::class 158 | ]) 159 | ->filterAnyModifiers([ 160 | ReflectionProperty::IS_PUBLIC, 161 | ReflectionProperty::IS_PROTECTED, 162 | ]) 163 | ->rejectAnyModifiers([ 164 | ReflectionProperty::IS_STATIC, 165 | ReflectionProperty::IS_READONLY, 166 | ]); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/Reflection/ReflectionClass.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Reflection; 8 | 9 | use ReflectionClass as BaseReflectionClass; 10 | use ReflectionMethod; 11 | use ReflectionProperty; 12 | 13 | class ReflectionClass extends BaseReflectionClass 14 | { 15 | public function collectMethods($filter = null): ReflectionCollection 16 | { 17 | return ReflectionCollection::make($this->getMethods($filter)); 18 | } 19 | 20 | public function collectProperties($filter = null): ReflectionCollection 21 | { 22 | return ReflectionCollection::make($this->getProperties($filter)); 23 | } 24 | 25 | public function withPublicMethods(?callable $cb = null): ReflectionCollection 26 | { 27 | return $this->collectMethods() 28 | ->filterAllModifiers([ 29 | ReflectionMethod::IS_PUBLIC 30 | ]) 31 | ->rejectAnyModifiers([ 32 | ReflectionMethod::IS_ABSTRACT, 33 | ReflectionMethod::IS_STATIC 34 | ]) 35 | // If `cb` is null then we use `value` as a noop. 36 | ->cleanMap(fn(ReflectionMethod $method) => ($cb ?? value(...))($method)); 37 | } 38 | 39 | public function withPublicProperties(?callable $cb = null): ReflectionCollection 40 | { 41 | return $this->collectProperties() 42 | ->filterAllModifiers([ 43 | ReflectionProperty::IS_PUBLIC, 44 | ]) 45 | ->rejectAnyModifiers([ 46 | ReflectionProperty::IS_STATIC, 47 | ]) 48 | // If `cb` is null then we use `value` as a noop. 49 | ->cleanMap(fn(ReflectionMethod $method) => ($cb ?? value(...))($method)); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Reflection/ReflectionCollection.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Reflection; 8 | 9 | /* 10 | * What a fun name! 11 | */ 12 | 13 | class ReflectionCollection extends \Illuminate\Support\Collection 14 | { 15 | public function cleanMap(?callable $cb = null): static 16 | { 17 | return $this 18 | ->when(!is_null($cb), fn($collection) => $collection->map($cb)) 19 | ->filter() 20 | ->values(); 21 | } 22 | 23 | public function filterAnyAnnotations(string|array $classes = []): static 24 | { 25 | return $this->filter(function ($reflection) use ($classes) { 26 | return Reflector::isAnnotatedByAny($reflection, $classes); 27 | }); 28 | } 29 | 30 | public function rejectAnyAnnotations(string|array $classes = []): static 31 | { 32 | return $this->reject(function ($reflection) use ($classes) { 33 | return Reflector::isAnnotatedByAny($reflection, $classes); 34 | }); 35 | } 36 | 37 | public function filterAllModifiers(int|array $mask): static 38 | { 39 | return $this->filter(function ($reflection) use ($mask) { 40 | return $this->allModifierBitsOn($reflection, $mask); 41 | }); 42 | } 43 | 44 | public function filterAnyModifiers(int|array $mask): static 45 | { 46 | return $this->filter(function ($reflection) use ($mask) { 47 | return $this->anyModifierBitsOn($reflection, $mask); 48 | }); 49 | } 50 | 51 | public function rejectAllModifiers(int|array $mask): static 52 | { 53 | return $this->reject(function ($reflection) use ($mask) { 54 | return $this->allModifierBitsOn($reflection, $mask); 55 | }); 56 | } 57 | 58 | public function rejectAnyModifiers(int|array $mask): static 59 | { 60 | return $this->reject(function ($reflection) use ($mask) { 61 | return $this->anyModifierBitsOn($reflection, $mask); 62 | }); 63 | } 64 | 65 | public function filterMany(array $filters): static 66 | { 67 | return $this->filter(function ($value) use ($filters) { 68 | foreach ($filters as $filter) { 69 | if (!$filter($value)) { 70 | return false; 71 | } 72 | } 73 | 74 | return true; 75 | }); 76 | } 77 | 78 | public function rejectMany(array $rejectors): static 79 | { 80 | return $this->reject(function ($value) use ($rejectors) { 81 | foreach ($rejectors as $rejector) { 82 | if ($rejector($value)) { 83 | return true; 84 | } 85 | } 86 | 87 | return false; 88 | }); 89 | } 90 | 91 | protected function allModifierBitsOn($reflection, int|array $mask): bool 92 | { 93 | $mask = is_array($mask) ? array_sum($mask) : $mask; 94 | 95 | return (Reflector::getModifiers($reflection) & $mask) === $mask; 96 | } 97 | 98 | protected function anyModifierBitsOn($reflection, int|array $mask): bool 99 | { 100 | $mask = is_array($mask) ? array_sum($mask) : $mask; 101 | 102 | return (Reflector::getModifiers($reflection) & $mask) !== 0; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Reflection/Reflector.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Reflection; 8 | 9 | use Illuminate\Support\Arr; 10 | use ReflectionMethod; 11 | use ReflectionProperty; 12 | 13 | class Reflector extends \Illuminate\Support\Reflector 14 | { 15 | public static function getModifiers($reflection): mixed 16 | { 17 | return match (get_class($reflection)) { 18 | ReflectionMethod::class, ReflectionProperty::class => $reflection->getModifiers(), 19 | }; 20 | } 21 | 22 | public static function isAnnotatedByAny($reflection, string|array $classes): bool 23 | { 24 | $classes = Arr::wrap($classes); 25 | 26 | // If it doesn't have a getAttributes method, it definitely doesn't have any annotations. 27 | $attributes = is_callable([$reflection, 'getAttributes']) ? $reflection->getAttributes() : []; 28 | 29 | if (!$attributes) { 30 | return false; 31 | } 32 | 33 | foreach ($classes as $class) { 34 | if ($reflection->getAttributes($class)) { 35 | return true; 36 | } 37 | } 38 | 39 | return false; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Routing/PendingBind.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Routing; 8 | 9 | use Closure; 10 | 11 | class PendingBind 12 | { 13 | public ?Closure $callback; 14 | 15 | public bool $withTrashed = false; 16 | 17 | public ?string $to = null; 18 | 19 | public ?string $using = null; 20 | 21 | public function __construct() 22 | { 23 | // 24 | } 25 | 26 | public function withTrashed(bool $trashed = true): static 27 | { 28 | $this->withTrashed = $trashed; 29 | 30 | return $this; 31 | } 32 | 33 | public function to(?string $model = null): static 34 | { 35 | $this->to = $model; 36 | 37 | return $this; 38 | } 39 | 40 | public function using(?string $key = null): static 41 | { 42 | $this->using = $key; 43 | 44 | return $this; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Routing/Registrar.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Routing; 8 | 9 | use Exception; 10 | use Fusion\Http\Controllers\FusionController; 11 | use Fusion\Models\Component; 12 | use Illuminate\Support\Collection; 13 | use Illuminate\Support\Facades\Route; 14 | use Illuminate\Support\Str; 15 | 16 | class Registrar 17 | { 18 | public function pages(string $root, array $files): void 19 | { 20 | $this->mapFiles($root, $files)->each(function ($config, $src) { 21 | $route = Route::any($config['uri'], [FusionController::class, 'handle']) 22 | // There might be a cleaner way to do this, but I think this sets us up for "know 23 | // nothing" route caching quite nicely. Will have to revisit to be sure. 24 | ->defaults('__component', $config['component']) 25 | ->defaults('__class', Component::where('src', $src)->first()?->php_class); 26 | 27 | if ($config['variadic'] !== null) { 28 | $route->where($config['variadic'], '.*') 29 | ->defaults('__variadic', $config['variadic']); 30 | } 31 | }); 32 | } 33 | 34 | public function mapFiles(string $root, array $files): Collection 35 | { 36 | return collect($files) 37 | ->mapWithKeys(fn($relative, $absolute) => [ 38 | Str::chopStart($absolute, base_path()) => $relative 39 | ]) 40 | ->map(function ($view) use ($root) { 41 | $uri = str($view) 42 | ->chopEnd('.vue') 43 | ->replace(DIRECTORY_SEPARATOR, '/') 44 | ->lower() 45 | ->start('/') 46 | ->start($root) 47 | ->chopEnd('/index'); 48 | 49 | $matches = $uri->matchAll('/\[([^\]]+)\]/'); 50 | 51 | $variadic = null; 52 | 53 | foreach ($matches as $match) { 54 | $name = str($match)->after('...')->lower()->value(); 55 | 56 | if (Str::startsWith($match, '...')) { 57 | if ($match !== $matches->last()) { 58 | throw new Exception('Wildcard path must be last.'); 59 | } 60 | 61 | $variadic = $name; 62 | } 63 | 64 | $uri = $uri->replace("[$match]", "{{$name}}"); 65 | } 66 | 67 | return [ 68 | 'uri' => $uri->value(), 69 | 'component' => str($view)->chopStart('/')->beforeLast('.')->value(), 70 | 'variadic' => $variadic, 71 | ]; 72 | }) 73 | ->sortBy(function ($route, $path) { 74 | // Split URI into segments and remove empty values 75 | $segments = array_filter(explode('/', $route['uri'])); 76 | $segmentCount = count($segments); 77 | 78 | // Determine if this is an index route (empty URI) 79 | $isIndex = $route['uri'] === ''; 80 | 81 | // Check if this is a root-level route 82 | $isRootLevel = $segmentCount <= 1; 83 | 84 | // Calculate route type priority 85 | // 0: index route ('') 86 | // 1: static routes with no parameters (/episodes, /podcasts) 87 | // 2: dynamic routes (/{podcast}, /episodes/{episode}) 88 | // 3: variadic routes in nested paths (/episodes/{wild}) 89 | // 4: root-level variadic routes (/{rest}) - these must come last 90 | $routeType = match (true) { 91 | $isIndex => 0, 92 | $isRootLevel && $route['variadic'] !== null => 4, 93 | $route['variadic'] !== null => 3, 94 | str_contains($route['uri'], '{') => 2, 95 | default => 1 96 | }; 97 | 98 | // For static and dynamic routes, sort by segment depth first 99 | // This ensures /episodes comes before /episodes/create 100 | return [ 101 | $routeType, 102 | $routeType === 1 ? $segmentCount : 0, // Only use segment count for static routes 103 | $route['uri'] 104 | ]; 105 | }); 106 | } 107 | 108 | public function page($uri, $component): \Illuminate\Routing\Route 109 | { 110 | $src = str($component) 111 | ->append('.vue') 112 | ->start(DIRECTORY_SEPARATOR) 113 | ->start(config('fusion.paths.pages')) 114 | ->chopStart(base_path()) 115 | ->value(); 116 | 117 | return Route::any($uri, [FusionController::class, 'handle']) 118 | // There might be a cleaner way to do this, but I think this sets us up for "know 119 | // nothing" route caching quite nicely. Will have to revisit to be sure. 120 | ->defaults('__component', $component) 121 | ->defaults('__class', Component::where('src', $src)->first()?->php_class); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Routing/SubstituteBindings.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Routing; 8 | 9 | use Illuminate\Contracts\Routing\UrlRoutable; 10 | use Illuminate\Database\Eloquent\ModelNotFoundException; 11 | use Illuminate\Database\Eloquent\SoftDeletes; 12 | use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException; 13 | use Illuminate\Routing\Router; 14 | use Illuminate\Support\Arr; 15 | use InvalidArgumentException; 16 | use UnitEnum; 17 | 18 | class SubstituteBindings 19 | { 20 | /** 21 | * Example structure: 22 | * [ 23 | * 'podcast' => [ 24 | * 'class' => \App\Models\Podcast::class, 25 | * 'key' => 'slug', 26 | * 'withTrashed' => true, 27 | * ], 28 | * 'status' => [ 29 | * 'class' => \App\Enums\PodcastStatus::class, 30 | * ], 31 | * ] 32 | */ 33 | protected array $bindings; 34 | 35 | public function __construct(array $bindings = []) 36 | { 37 | $this->bindings = $bindings; 38 | } 39 | 40 | public function resolve(array $parameters): array 41 | { 42 | $resolved = []; 43 | $router = app(Router::class); 44 | 45 | foreach ($parameters as $name => $value) { 46 | $resolved[$name] = $value; 47 | if (!isset($this->bindings[$name])) { 48 | continue; 49 | } 50 | 51 | $binding = $this->bindings[$name]; 52 | $class = $binding['class'] ?? null; 53 | 54 | if ($router->hasExplicitBinding($name)) { 55 | $resolved[$name] = $router->performExplicitBinding($name, $value); 56 | } elseif (is_null($class)) { 57 | continue; 58 | } elseif (!class_exists($class)) { 59 | throw new InvalidArgumentException("Model class [{$class}] does not exist."); 60 | } elseif (is_a($class, UrlRoutable::class, true)) { 61 | $resolved[$name] = $this->resolveModelBinding($binding, $value); 62 | } elseif (enum_exists($class)) { 63 | $resolved[$name] = $this->resolveEnumBinding($class, $value); 64 | } 65 | } 66 | 67 | return $resolved; 68 | } 69 | 70 | protected function resolveModelBinding(array $binding, mixed $value): UrlRoutable 71 | { 72 | $class = $binding['class']; 73 | 74 | /** @var UrlRoutable $instance */ 75 | $instance = new $class; 76 | 77 | $field = $binding['key'] ?? $instance->getRouteKeyName(); 78 | 79 | $routeBindingMethod = $this->allowsTrashed($binding, $instance) 80 | ? 'resolveSoftDeletableRouteBinding' 81 | : 'resolveRouteBinding'; 82 | 83 | // If the incoming param is an array but it's supposed to be a model, odds are 84 | // good that the frontend sent the entire object instead of just the route 85 | // key. If the route key (field) is present in the array, we'll use that. 86 | $value = is_array($value) ? Arr::get($value, $field, $value) : $value; 87 | 88 | $model = $instance->{$routeBindingMethod}($value, $field); 89 | 90 | if (!$model) { 91 | throw (new ModelNotFoundException)->setModel($class, [$value]); 92 | } 93 | 94 | return $model; 95 | } 96 | 97 | protected function allowsTrashed($binding, $instance): bool 98 | { 99 | return isset($binding['withTrashed']) 100 | && $binding['withTrashed'] 101 | && in_array(SoftDeletes::class, class_uses_recursive($instance)); 102 | } 103 | 104 | protected function resolveEnumBinding(string $class, $value): UnitEnum 105 | { 106 | $enum = $value instanceof $class ? $value : $class::tryFrom((string) $value); 107 | 108 | if (is_null($enum)) { 109 | throw new BackedEnumCaseNotFoundException($class, $value); 110 | } 111 | 112 | return $enum; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Support/Fluent.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Support; 8 | 9 | class Fluent extends \Illuminate\Support\Fluent 10 | { 11 | public function merge($key, array $value): Fluent 12 | { 13 | $array = $this->get($key, []); 14 | 15 | $array = [...$array, ...$value]; 16 | 17 | return $this->set($key, $array); 18 | } 19 | 20 | public function push($key, $value): Fluent 21 | { 22 | $array = $this->get($key, []); 23 | 24 | $array[] = $value; 25 | 26 | return $this->set($key, $array); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Support/PendingProp.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | namespace Fusion\Support; 8 | 9 | use Closure; 10 | use Fusion\Routing\PendingBind; 11 | 12 | class PendingProp 13 | { 14 | public string $name; 15 | 16 | public mixed $default = null; 17 | 18 | public ?Closure $valueResolver = null; 19 | 20 | public ?string $fromRoute = null; 21 | 22 | public ?string $queryStringName = null; 23 | 24 | public ?PendingBind $binding = null; 25 | 26 | public bool $readonly = false; 27 | 28 | public function __construct(string $name, $default = null) 29 | { 30 | $this->name = $name; 31 | $this->default = $default; 32 | } 33 | 34 | public function setValueResolver(Closure $closure): static 35 | { 36 | $this->valueResolver = $closure; 37 | 38 | return $this; 39 | } 40 | 41 | public function fromRoute( 42 | ?string $param = null, 43 | ?string $class = null, 44 | ?string $using = null, 45 | bool $withTrashed = false 46 | ): static { 47 | $this->fromRoute = $param ?? $this->name; 48 | $this->readonly = true; 49 | 50 | $this->binding = (new PendingBind)->to($class)->using($using)->withTrashed($withTrashed); 51 | 52 | return $this; 53 | } 54 | 55 | public function syncQueryString(?string $as = null): static 56 | { 57 | $this->queryStringName = $as ?? $this->name; 58 | 59 | return $this; 60 | } 61 | 62 | public function readonly(bool $readonly = true): static 63 | { 64 | $this->readonly = $readonly; 65 | 66 | return $this; 67 | } 68 | 69 | public function value(): mixed 70 | { 71 | return call_user_func($this->valueResolver, $this); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /testbench.yaml: -------------------------------------------------------------------------------- 1 | providers: 2 | - Fusion\Providers\FusionServiceProvider 3 | - App\Providers\AppServiceProvider 4 | 5 | migrations: false 6 | 7 | workbench: 8 | start: '/' 9 | health: true 10 | discovers: 11 | web: true 12 | commands: true 13 | --------------------------------------------------------------------------------