├── 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 |
--------------------------------------------------------------------------------