diff --git a/scripts/setupFixtures.mjs b/scripts/setupFixtures.mjs index d82dccd..9995e5c 100644 --- a/scripts/setupFixtures.mjs +++ b/scripts/setupFixtures.mjs @@ -1,6 +1,7 @@ import path from "path" import { glob } from "glob" import { execSync } from "child_process" +import fs from "fs" const fixtures = await glob("test/fixtures/**/package.json", { ignore: "**/**/node_modules/**" }) @@ -10,4 +11,15 @@ fixtures.forEach(async fixturesPath => { console.log(`Installing packages for fixture: ${fixtureFolder}`) execSync(`cd ${fixtureFolder} && yarn install && cd -`) + + if (fixtureFolder.endsWith("symfony-asset-mapper")) { + const mockPackagePath = path.join(fixtureFolder, "node_modules", "@symfony", "stimulus-bundle") + fs.mkdirSync(mockPackagePath, { recursive: true }) + fs.writeFileSync(path.join(mockPackagePath, "package.json"), JSON.stringify({ + name: "@symfony/stimulus-bundle", + version: "3.2.0", + main: "loader.js" + }, null, 2)) + fs.writeFileSync(path.join(mockPackagePath, "loader.js"), "export function startStimulusApp() { return {} }\n") + } }) diff --git a/src/controllers_index_file.ts b/src/controllers_index_file.ts index d89cc27..66411af 100644 --- a/src/controllers_index_file.ts +++ b/src/controllers_index_file.ts @@ -44,6 +44,57 @@ export class ControllersIndexFile { await this.analyzeEsbuildRails() await this.analyzeStimulusViteHelpers() await this.analyzeStimulusWebpackHelpers() + await this.analyzeSymfonyStimulusBridge() + } + + async analyzeSymfonyStimulusBridge() { + const hasStimulusBridge = await hasDepedency(this.project.projectPath, "@symfony/stimulus-bridge") + const isAssetMapper = this.project.isAssetMapper + const hasStimulusBundle = this.sourceFile.hasStimulusBundleImport + + if (!hasStimulusBridge && !isAssetMapper && !hasStimulusBundle) return + + let controllerRoot = "" + + walk(this.sourceFile.ast, { + CallExpression: node => { + if (node.callee.type !== "Identifier" || node.callee.name !== "startStimulusApp") return + + const [contextNode] = node.arguments.slice(0, 1) + + if (contextNode && contextNode.type === "CallExpression" && + contextNode.callee.type === "MemberExpression" && + contextNode.callee.object.type === "Identifier" && + contextNode.callee.property.type === "Identifier" && + contextNode.callee.object.name === "require" && + contextNode.callee.property.name === "context" && + contextNode.arguments.length >= 1) { + + const pathNode = contextNode.arguments[0] + if (pathNode.type !== "Literal") return + + const requirePath = pathNode.value as string + if (!requirePath) return + + const lazyLoaderPrefix = "@symfony/stimulus-bridge/lazy-controller-loader!" + if (!requirePath.startsWith(lazyLoaderPrefix)) return + + const relativePath = requirePath.slice(lazyLoaderPrefix.length) + const thisFileDir = path.dirname(this.sourceFile.path) + controllerRoot = path.join(thisFileDir, relativePath) + } else if ((isAssetMapper || hasStimulusBundle) && !contextNode) { + const thisFileDir = path.dirname(this.sourceFile.path) + controllerRoot = path.join(thisFileDir, "controllers") + } + } + }) + + if (controllerRoot) { + const relativeControllerRoot = this.project.relativePath(controllerRoot) + this.project._controllerRoots.add(relativeControllerRoot) + const controllersGlob = path.join(controllerRoot, `**/*.{${this.project.extensionsGlob}}`) + await this.evaluateControllerGlob(relativeControllerRoot, controllersGlob, "stimulus-loading-lazy") + } } analyzeApplicationRegisterCalls() { diff --git a/src/packages.ts b/src/packages.ts index 305f986..7cb1d9f 100644 --- a/src/packages.ts +++ b/src/packages.ts @@ -8,6 +8,8 @@ import { findNodeModulesPath, parsePackageJSON, nodeModuleForPackageJSONPath, ha export const helperPackages = [ "@hotwired/stimulus-loading", "@hotwired/stimulus-webpack-helpers", + "@symfony/stimulus-bridge", + "@symfony/stimulus-bundle", "bun-stimulus-plugin", "esbuild-plugin-stimulus", "stimulus-vite-helpers", diff --git a/src/project.ts b/src/project.ts index 7236eda..4a221f3 100644 --- a/src/project.ts +++ b/src/project.ts @@ -1,3 +1,6 @@ +import path from "path" +import { existsSync } from "fs" + import { glob } from "glob" import { ApplicationFile } from "./application_file" @@ -34,6 +37,17 @@ export class Project { this.projectPath = projectPath } + private _isAssetMapper?: boolean + + get isAssetMapper(): boolean { + if (this._isAssetMapper !== undefined) return this._isAssetMapper + const importmapPath = path.join(this.projectPath, "importmap.php") + const stimulusConfigPath = path.join(this.projectPath, "config", "packages", "stimulus.yaml") + this._isAssetMapper = existsSync(importmapPath) || existsSync(stimulusConfigPath) + + return this._isAssetMapper + } + relativePath(path: string) { return path.replace(`${this.projectPath}/`, "") } diff --git a/src/source_file.ts b/src/source_file.ts index fd08455..7439188 100644 --- a/src/source_file.ts +++ b/src/source_file.ts @@ -93,6 +93,8 @@ export class SourceFile { } get hasResolvedStimulusApplicationFileImport() { + if (!this.project.applicationFile) return false + return !!this.importDeclarations.find(declaration => this.project.applicationFile?.path === declaration.resolvedRelativePath) } @@ -100,9 +102,14 @@ export class SourceFile { return this.importDeclarations.some(declaration => helperPackages.includes(declaration.source)) } + get hasStimulusBundleImport() { + return this.importDeclarations.some(declaration => declaration.source === "@symfony/stimulus-bundle") + } + get isStimulusControllersIndex() { if (this.hasHelperPackage) return true if (this.hasResolvedStimulusApplicationFileImport) return true + if (this.hasStimulusBundleImport && this.project.isAssetMapper) return true return false } diff --git a/test/fixtures/symfony-asset-mapper/assets/bootstrap.js b/test/fixtures/symfony-asset-mapper/assets/bootstrap.js new file mode 100644 index 0000000..e3f76d4 --- /dev/null +++ b/test/fixtures/symfony-asset-mapper/assets/bootstrap.js @@ -0,0 +1,3 @@ +import { startStimulusApp } from '@symfony/stimulus-bundle' + +const app = startStimulusApp() diff --git a/test/fixtures/symfony-asset-mapper/assets/controllers/hello_controller.js b/test/fixtures/symfony-asset-mapper/assets/controllers/hello_controller.js new file mode 100644 index 0000000..cb8d421 --- /dev/null +++ b/test/fixtures/symfony-asset-mapper/assets/controllers/hello_controller.js @@ -0,0 +1,9 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + static targets = ['output'] + + connect() { + console.log('Hello!') + } +} diff --git a/test/fixtures/symfony-asset-mapper/package.json b/test/fixtures/symfony-asset-mapper/package.json new file mode 100644 index 0000000..3938ab1 --- /dev/null +++ b/test/fixtures/symfony-asset-mapper/package.json @@ -0,0 +1,6 @@ +{ + "name": "test-symfony-asset-mapper", + "dependencies": { + "@hotwired/stimulus": "^3.2.0" + } +} diff --git a/test/fixtures/symfony-asset-mapper/yarn.lock b/test/fixtures/symfony-asset-mapper/yarn.lock new file mode 100644 index 0000000..2e71b75 --- /dev/null +++ b/test/fixtures/symfony-asset-mapper/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@hotwired/stimulus@^3.2.0": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.2.tgz#071aab59c600fed95b97939e605ff261a4251608" + integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A== diff --git a/test/fixtures/symfony-stimulus-bridge/assets/bootstrap.js b/test/fixtures/symfony-stimulus-bridge/assets/bootstrap.js new file mode 100644 index 0000000..666065f --- /dev/null +++ b/test/fixtures/symfony-stimulus-bridge/assets/bootstrap.js @@ -0,0 +1,7 @@ +import { startStimulusApp } from '@symfony/stimulus-bridge' + +export const app = startStimulusApp(require.context( + '@symfony/stimulus-bridge/lazy-controller-loader!./controllers', + true, + /\.[jt]sx?$/ +)) diff --git a/test/fixtures/symfony-stimulus-bridge/assets/controllers/hello-controller.js b/test/fixtures/symfony-stimulus-bridge/assets/controllers/hello-controller.js new file mode 100644 index 0000000..cb8d421 --- /dev/null +++ b/test/fixtures/symfony-stimulus-bridge/assets/controllers/hello-controller.js @@ -0,0 +1,9 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + static targets = ['output'] + + connect() { + console.log('Hello!') + } +} diff --git a/test/fixtures/symfony-stimulus-bridge/package.json b/test/fixtures/symfony-stimulus-bridge/package.json new file mode 100644 index 0000000..dbccd53 --- /dev/null +++ b/test/fixtures/symfony-stimulus-bridge/package.json @@ -0,0 +1,7 @@ +{ + "name": "test-symfony-stimulus-bridge", + "dependencies": { + "@hotwired/stimulus": "^3.2.0", + "@symfony/stimulus-bridge": "^3.2.0" + } +} diff --git a/test/fixtures/symfony-stimulus-bridge/yarn.lock b/test/fixtures/symfony-stimulus-bridge/yarn.lock new file mode 100644 index 0000000..67a3b47 --- /dev/null +++ b/test/fixtures/symfony-stimulus-bridge/yarn.lock @@ -0,0 +1,114 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@hotwired/stimulus-webpack-helpers@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@hotwired/stimulus-webpack-helpers/-/stimulus-webpack-helpers-1.0.1.tgz#4cd74487adeca576c9865ac2b9fe5cb20cef16dd" + integrity sha512-wa/zupVG0eWxRYJjC1IiPBdt3Lruv0RqGN+/DTMmUWUyMAEB27KXmVY6a8YpUVTM7QwVuaLNGW4EqDgrS2upXQ== + +"@hotwired/stimulus@^3.2.0": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.2.tgz#071aab59c600fed95b97939e605ff261a4251608" + integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A== + +"@symfony/stimulus-bridge@^3.2.0": + version "3.2.3" + resolved "https://registry.yarnpkg.com/@symfony/stimulus-bridge/-/stimulus-bridge-3.2.3.tgz#1c496d4b11e24051be26a11045118f29f9c3f9b7" + integrity sha512-36rQTihQ2MGOn8EmdOYCr3DQfP3WS1CNcUUXKTPY5ghtFOeb7OVuhbc32AjRowE2/vaVDOUCPOTv3VLf5VtXBA== + dependencies: + "@hotwired/stimulus-webpack-helpers" "^1.0.1" + "@types/webpack-env" "^1.16.4" + acorn "^8.0.5" + loader-utils "^2.0.0" + schema-utils "^3.0.0" + +"@types/json-schema@^7.0.8": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/webpack-env@^1.16.4": + version "1.18.8" + resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.18.8.tgz#71f083718c094204d7b64443701d32f1db3989e3" + integrity sha512-G9eAoJRMLjcvN4I08wB5I7YofOb/kaJNd5uoCMX+LbKXTPCF+ZIHuqTnFaK9Jz1rgs035f9JUPUhNFtqgucy/A== + +acorn@^8.0.5: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +ajv-keywords@^3.5.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" + integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== + +ajv@^6.12.5: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + +fast-deep-equal@^3.1.1: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json5@^2.1.2: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +loader-utils@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" + integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +schema-utils@^3.0.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" + integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== + dependencies: + "@types/json-schema" "^7.0.8" + ajv "^6.12.5" + ajv-keywords "^3.5.2" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" diff --git a/test/system/symfony-stimulus-bridge.test.ts b/test/system/symfony-stimulus-bridge.test.ts new file mode 100644 index 0000000..c0204a3 --- /dev/null +++ b/test/system/symfony-stimulus-bridge.test.ts @@ -0,0 +1,64 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("symfony-stimulus-bridge") + +describe("System", () => { + test("symfony-stimulus-bridge", async () => { + expect(project.controllersIndexFiles.length).toEqual(0) + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.applicationFile).toBeUndefined() + expect(project.controllersIndexFiles.length).toBeGreaterThanOrEqual(1) + + const bootstrapFile = project.controllersIndexFiles.find((f) => + f.path.endsWith("bootstrap.js"), + ) + expect(bootstrapFile).toBeDefined() + expect(bootstrapFile?.applicationImport).toBeUndefined() + + expect(project.registeredControllers.length).toEqual(1) + expect( + project.registeredControllers.map((controller) => [ + controller.identifier, + controller.loadMode, + ]), + ).toEqual([["hello", "stimulus-loading-lazy"]]) + expect(Array.from(project.controllerRoots).sort()).toContain( + "assets/controllers", + ) + }) + + test("symfony-asset-mapper", async () => { + const assetMapperProject = setupProject("symfony-asset-mapper") + + expect(assetMapperProject.controllersIndexFiles.length).toEqual(0) + expect(assetMapperProject.applicationFile).toBeUndefined() + expect(assetMapperProject.registeredControllers.length).toEqual(0) + + await assetMapperProject.initialize() + + expect(assetMapperProject.applicationFile).toBeUndefined() + expect(assetMapperProject.controllersIndexFiles.length).toBeGreaterThanOrEqual(1) + + const bootstrapFile = assetMapperProject.controllersIndexFiles.find((f) => + f.path.endsWith("bootstrap.js"), + ) + expect(bootstrapFile).toBeDefined() + expect(bootstrapFile?.applicationImport).toBeUndefined() + + expect(assetMapperProject.registeredControllers.length).toEqual(1) + expect( + assetMapperProject.registeredControllers.map((controller) => [ + controller.identifier, + controller.loadMode, + ]), + ).toEqual([["hello", "stimulus-loading-lazy"]]) + expect(Array.from(assetMapperProject.controllerRoots).sort()).toContain( + "assets/controllers", + ) + }) +})