├── src └── main │ ├── resources │ ├── messages │ │ └── AlpineBundle.properties │ ├── alpineicon.svg │ └── META-INF │ │ ├── pluginIcon.svg │ │ └── plugin.xml │ └── kotlin │ └── com │ └── github │ └── inxilpro │ └── intellijalpine │ ├── Alpine.kt │ ├── core │ ├── detection │ │ ├── DetectionStrategy.kt │ │ ├── PluginDetector.kt │ │ ├── PackageJsonDetector.kt │ │ └── ScriptReferenceDetector.kt │ ├── CompletionProviderRegistration.kt │ ├── AlpinePlugin.kt │ ├── AlpineLineMarkerProvider.kt │ └── AlpinePluginRegistry.kt │ ├── settings │ ├── AlpineProjectListener.kt │ ├── AlpineProjectActivity.kt │ ├── AlpineSettingsState.kt │ ├── AlpineProjectSettingsState.kt │ ├── AlpineSettingsComponent.kt │ └── AlpineSettingsConfigurable.kt │ ├── attributes │ ├── AttributesProvider.kt │ ├── AlpineAttributeDescriptor.kt │ ├── AttributeInfo.kt │ └── AttributeUtil.kt │ ├── completion │ ├── AutoPopupHandler.kt │ ├── AlpinePluginCompletionProviderWrapper.kt │ ├── AlpineCompletionContributor.kt │ ├── AlpineAttributeCompletionProvider.kt │ └── AutoCompleteSuggestions.kt │ ├── support │ ├── XmlExtension.kt │ └── LanguageUtil.kt │ ├── plugins │ ├── AlpineMergeValueCompletionProvider.kt │ ├── TooltipPlugin.kt │ ├── AlpineAjaxPlugin.kt │ ├── AlpineTargetReferenceContributor.kt │ └── AlpineWizardPlugin.kt │ └── injection │ └── AlpineJavaScriptAttributeValueInjector.kt ├── .gitignore ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── settings.gradle.kts ├── qodana.yml ├── .github ├── dependabot.yml └── workflows │ ├── run-ui-tests.yml │ ├── release.yml │ └── build.yml ├── .run ├── Run IDE for UI Tests.run.xml ├── Run IDE with Plugin.run.xml ├── Run Plugin Tests.run.xml ├── Run Qodana.run.xml └── Run Plugin Verification.run.xml ├── LICENSE ├── gradle.properties ├── CLAUDE.md ├── gradlew.bat ├── README.md ├── CHANGELOG.md └── gradlew /src/main/resources/messages/AlpineBundle.properties: -------------------------------------------------------------------------------- 1 | name=Alpine.js 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | .idea 3 | .intellijPlatform 4 | .kotlin 5 | .qodana 6 | build 7 | .DS_Store -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inxilpro/IntellijAlpine/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" 3 | } 4 | 5 | rootProject.name = "IntellijAlpine" 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/Alpine.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine 2 | 3 | import com.intellij.openapi.util.IconLoader 4 | 5 | object Alpine { 6 | @JvmField 7 | val ICON = IconLoader.getIcon("/alpineicon.svg", javaClass) 8 | } 9 | -------------------------------------------------------------------------------- /qodana.yml: -------------------------------------------------------------------------------- 1 | # Qodana configuration: 2 | # https://www.jetbrains.com/help/qodana/qodana-yaml.html 3 | 4 | version: "1.0" 5 | linter: jetbrains/qodana-jvm-community:2025.1 6 | projectJDK: "21" 7 | profile: 8 | name: qodana.recommended 9 | exclude: 10 | - name: All 11 | paths: 12 | - .qodana -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/DetectionStrategy.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.core.detection 2 | 3 | import com.github.inxilpro.intellijalpine.core.AlpinePlugin 4 | import com.intellij.openapi.project.Project 5 | 6 | interface DetectionStrategy { 7 | fun detect(project: Project, plugin: AlpinePlugin): Boolean 8 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineProjectListener.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.settings 2 | 3 | import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry 4 | import com.intellij.openapi.project.Project 5 | import com.intellij.openapi.project.ProjectManagerListener 6 | 7 | class AlpineProjectListener : ProjectManagerListener { 8 | override fun projectClosed(project: Project) { 9 | AlpinePluginRegistry.instance.cleanup(project) 10 | } 11 | } -------------------------------------------------------------------------------- /src/main/resources/alpineicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot configuration: 2 | # https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | # Maintain dependencies for Gradle dependencies 7 | - package-ecosystem: "gradle" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | # Maintain dependencies for GitHub Actions 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/pluginIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/core/CompletionProviderRegistration.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.core 2 | 3 | import com.github.inxilpro.intellijalpine.completion.AlpinePluginCompletionProvider 4 | import com.intellij.codeInsight.completion.CompletionType 5 | import com.intellij.patterns.ElementPattern 6 | import com.intellij.psi.PsiElement 7 | 8 | data class CompletionProviderRegistration( 9 | val pattern: ElementPattern, 10 | val provider: AlpinePluginCompletionProvider, 11 | val type: CompletionType = CompletionType.BASIC, 12 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineProjectActivity.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.settings 2 | 3 | import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry 4 | import com.intellij.openapi.application.readAction 5 | import com.intellij.openapi.project.Project 6 | import com.intellij.openapi.startup.ProjectActivity 7 | 8 | class AlpineProjectActivity : ProjectActivity { 9 | override suspend fun execute(project: Project) { 10 | readAction { 11 | AlpinePluginRegistry.instance.checkAndAutoEnablePlugins(project) 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/PluginDetector.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.core.detection 2 | 3 | import com.github.inxilpro.intellijalpine.core.AlpinePlugin 4 | import com.intellij.openapi.project.Project 5 | 6 | class PluginDetector : DetectionStrategy { 7 | private val strategies: List = listOf( 8 | PackageJsonDetector(), 9 | ScriptReferenceDetector() 10 | ) 11 | 12 | override fun detect(project: Project, plugin: AlpinePlugin): Boolean { 13 | return strategies.any { it.detect(project, plugin) } || plugin.performDetection(project) 14 | } 15 | } -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | # libraries 3 | junit = "4.13.2" 4 | opentest4j = "1.3.0" 5 | 6 | # plugins 7 | changelog = "2.2.1" 8 | intelliJPlatform = "2.6.0" 9 | kotlin = "2.1.21" 10 | kover = "0.9.1" 11 | qodana = "2025.1.1" 12 | 13 | [libraries] 14 | junit = { group = "junit", name = "junit", version.ref = "junit" } 15 | opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "opentest4j" } 16 | 17 | [plugins] 18 | changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } 19 | intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" } 20 | kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 21 | kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } 22 | qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/attributes/AttributesProvider.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.attributes 2 | 3 | import com.intellij.psi.impl.source.html.dtd.HtmlElementDescriptorImpl 4 | import com.intellij.psi.xml.XmlTag 5 | import com.intellij.xml.XmlAttributeDescriptor 6 | import com.intellij.xml.XmlAttributeDescriptorsProvider 7 | 8 | class AttributesProvider : XmlAttributeDescriptorsProvider { 9 | override fun getAttributeDescriptors(xmlTag: XmlTag): Array { 10 | return emptyArray() 11 | } 12 | 13 | @Suppress("ReturnCount") 14 | override fun getAttributeDescriptor(name: String, xmlTag: XmlTag): XmlAttributeDescriptor? { 15 | if (xmlTag.descriptor !is HtmlElementDescriptorImpl) return null 16 | val info = AttributeInfo(name) 17 | 18 | if (info.isAlpine()) { 19 | return AlpineAttributeDescriptor(name, xmlTag) 20 | } 21 | 22 | return null 23 | } 24 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsState.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.settings 2 | 3 | import com.intellij.openapi.application.ApplicationManager 4 | import com.intellij.openapi.components.PersistentStateComponent 5 | import com.intellij.openapi.components.State 6 | import com.intellij.openapi.components.Storage 7 | import com.intellij.util.xmlb.XmlSerializerUtil 8 | 9 | @State(name = "com.github.inxilpro.intellijalpine.AppSettingsState", storages = [Storage("IntellijAlpine.xml")]) 10 | class AlpineSettingsState : PersistentStateComponent { 11 | var showGutterIcons = true 12 | 13 | override fun getState(): AlpineSettingsState? { 14 | return this 15 | } 16 | 17 | override fun loadState(state: AlpineSettingsState) { 18 | XmlSerializerUtil.copyBean(state, this) 19 | } 20 | 21 | companion object { 22 | val instance: AlpineSettingsState 23 | get() = ApplicationManager.getApplication().getService(AlpineSettingsState::class.java) 24 | } 25 | } -------------------------------------------------------------------------------- /.run/Run IDE for UI Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 15 | 17 | true 18 | true 19 | false 20 | 21 | 22 | -------------------------------------------------------------------------------- /.run/Run IDE with Plugin.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /.run/Run Plugin Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.run/Run Qodana.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 16 | 19 | 21 | true 22 | true 23 | false 24 | 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2023 Chris Morrell 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 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/completion/AutoPopupHandler.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.completion 2 | 3 | import com.intellij.codeInsight.AutoPopupController 4 | import com.intellij.codeInsight.editorActions.TypedHandlerDelegate 5 | import com.intellij.codeInsight.lookup.LookupManager 6 | import com.intellij.openapi.editor.Editor 7 | import com.intellij.openapi.project.Project 8 | import com.intellij.psi.PsiFile 9 | import com.intellij.psi.html.HtmlTag 10 | 11 | class AutoPopupHandler : TypedHandlerDelegate() { 12 | @Suppress("ReturnCount") 13 | override fun checkAutoPopup(charTyped: Char, project: Project, editor: Editor, file: PsiFile): Result { 14 | if (LookupManager.getActiveLookup(editor) != null) { 15 | return Result.CONTINUE 16 | } 17 | 18 | val element = file.findElementAt(editor.caretModel.offset) 19 | if (element?.parent !is HtmlTag) { 20 | return Result.CONTINUE 21 | } 22 | 23 | if (charTyped == '@' || charTyped == ':' || charTyped == '.') { 24 | AutoPopupController.getInstance(project).scheduleAutoPopup(editor) 25 | return Result.STOP 26 | } 27 | 28 | return Result.CONTINUE 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/completion/AlpinePluginCompletionProviderWrapper.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.completion 2 | 3 | import com.github.inxilpro.intellijalpine.core.AlpinePlugin 4 | import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry 5 | import com.intellij.codeInsight.completion.CompletionParameters 6 | import com.intellij.codeInsight.completion.CompletionProvider 7 | import com.intellij.codeInsight.completion.CompletionResultSet 8 | import com.intellij.util.ProcessingContext 9 | 10 | abstract class AlpinePluginCompletionProvider( 11 | private val plugin: AlpinePlugin 12 | ) : CompletionProvider() { 13 | 14 | final override fun addCompletions( 15 | parameters: CompletionParameters, 16 | context: ProcessingContext, 17 | result: CompletionResultSet 18 | ) { 19 | if (AlpinePluginRegistry.instance.isPluginEnabled(parameters.position.project, plugin)) { 20 | addPluginCompletions(parameters, context, result) 21 | } 22 | } 23 | 24 | protected abstract fun addPluginCompletions( 25 | parameters: CompletionParameters, 26 | context: ProcessingContext, 27 | result: CompletionResultSet 28 | ) 29 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/completion/AlpineCompletionContributor.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.completion 2 | 3 | import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry 4 | import com.intellij.codeInsight.completion.CompletionContributor 5 | import com.intellij.codeInsight.completion.CompletionType 6 | import com.intellij.patterns.PlatformPatterns 7 | import com.intellij.patterns.XmlPatterns 8 | import com.intellij.psi.xml.XmlTokenType 9 | 10 | class AlpineCompletionContributor : CompletionContributor() { 11 | init { 12 | // Attribute name completion 13 | extend( 14 | CompletionType.BASIC, 15 | PlatformPatterns.psiElement(XmlTokenType.XML_NAME).withParent(XmlPatterns.xmlAttribute()), 16 | AlpineAttributeCompletionProvider() 17 | ) 18 | 19 | // Plugin completions 20 | AlpinePluginRegistry.instance.getRegisteredPlugins().forEach { plugin -> 21 | plugin.getCompletionProviders().forEach { registration -> 22 | extend( 23 | registration.type, 24 | registration.pattern, 25 | registration.provider 26 | ) 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /.run/Run Plugin Verification.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | 23 | 25 | 26 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/core/AlpinePlugin.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.core 2 | 3 | import com.github.inxilpro.intellijalpine.attributes.AttributeInfo 4 | import com.github.inxilpro.intellijalpine.completion.AutoCompleteSuggestions 5 | import com.intellij.openapi.extensions.ExtensionPointName 6 | import com.intellij.openapi.project.Project 7 | import org.apache.commons.lang3.tuple.MutablePair 8 | 9 | interface AlpinePlugin { 10 | companion object { 11 | val EP_NAME = 12 | ExtensionPointName.Companion.create("com.github.inxilpro.intellijalpine.alpinePlugin") 13 | } 14 | 15 | fun getPluginName(): String 16 | 17 | fun getPackageDisplayName(): String 18 | 19 | fun getPackageNamesForDetection(): List 20 | 21 | fun getTypeText(info: AttributeInfo): String? = null 22 | 23 | fun injectJsContext(context: MutablePair): MutablePair = context 24 | 25 | fun directiveSupportJavaScript(directive: String): Boolean = true 26 | 27 | fun injectAutoCompleteSuggestions(suggestions: AutoCompleteSuggestions) {} 28 | 29 | fun getCompletionProviders(): List = emptyList() 30 | 31 | fun getDirectives(): List = emptyList() 32 | 33 | fun getPrefixes(): List = emptyList() 34 | 35 | fun performDetection(project: Project): Boolean = false 36 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineProjectSettingsState.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.settings 2 | 3 | import com.intellij.openapi.components.PersistentStateComponent 4 | import com.intellij.openapi.components.State 5 | import com.intellij.openapi.components.Storage 6 | import com.intellij.openapi.project.Project 7 | import com.intellij.util.xmlb.XmlSerializerUtil 8 | 9 | @State( 10 | name = "com.github.inxilpro.intellijalpine.AlpineProjectSettingsState", 11 | storages = [Storage("alpinejs-support.xml")] 12 | ) 13 | class AlpineProjectSettingsState : PersistentStateComponent { 14 | var enabledPlugins = mutableMapOf() 15 | 16 | fun isPluginEnabled(pluginName: String): Boolean { 17 | return enabledPlugins[pluginName] ?: false 18 | } 19 | 20 | fun setPluginEnabled(pluginName: String, enabled: Boolean) { 21 | enabledPlugins[pluginName] = enabled 22 | } 23 | 24 | override fun getState(): AlpineProjectSettingsState? { 25 | return this 26 | } 27 | 28 | override fun loadState(state: AlpineProjectSettingsState) { 29 | XmlSerializerUtil.copyBean(state, this) 30 | } 31 | 32 | companion object { 33 | fun getInstance(project: Project): AlpineProjectSettingsState { 34 | return project.getService(AlpineProjectSettingsState::class.java) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/support/XmlExtension.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.support 2 | 3 | import com.github.inxilpro.intellijalpine.attributes.AttributeUtil 4 | import com.intellij.openapi.util.TextRange 5 | import com.intellij.psi.PsiFile 6 | import com.intellij.psi.html.HtmlTag 7 | import com.intellij.psi.impl.source.xml.SchemaPrefix 8 | import com.intellij.psi.xml.XmlTag 9 | import com.intellij.xml.HtmlXmlExtension 10 | 11 | class XmlExtension : HtmlXmlExtension() { 12 | override fun isAvailable(file: PsiFile?): Boolean { 13 | if (file == null) return false 14 | return LanguageUtil.supportsAlpineJs(file) 15 | } 16 | 17 | override fun getPrefixDeclaration(context: XmlTag, namespacePrefix: String?): SchemaPrefix? { 18 | if (null != namespacePrefix && context is HtmlTag && hasAlpinePrefix(namespacePrefix)) { 19 | findAttributeSchema(context, namespacePrefix) 20 | ?.let { return it } 21 | } 22 | 23 | return super.getPrefixDeclaration(context, namespacePrefix) 24 | } 25 | 26 | private fun hasAlpinePrefix(namespacePrefix: String): Boolean { 27 | return AttributeUtil.isXmlPrefix(namespacePrefix) 28 | } 29 | 30 | private fun findAttributeSchema(context: XmlTag, namespacePrefix: String): SchemaPrefix? { 31 | return context.attributes 32 | .find { it.name.startsWith(namespacePrefix) } 33 | ?.let { SchemaPrefix(it, TextRange.create(0, namespacePrefix.length), "Alpine.js") } 34 | } 35 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/attributes/AlpineAttributeDescriptor.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.attributes 2 | 3 | import com.github.inxilpro.intellijalpine.Alpine 4 | import com.intellij.psi.PsiElement 5 | import com.intellij.psi.meta.PsiPresentableMetaData 6 | import com.intellij.psi.xml.XmlTag 7 | import com.intellij.util.ArrayUtil 8 | import com.intellij.xml.impl.BasicXmlAttributeDescriptor 9 | 10 | class AlpineAttributeDescriptor( 11 | private val name: String, 12 | private val xmlTag: XmlTag 13 | ) : 14 | BasicXmlAttributeDescriptor(), 15 | PsiPresentableMetaData { 16 | 17 | private val info: AttributeInfo = AttributeInfo(name) 18 | 19 | override fun getIcon() = Alpine.ICON 20 | 21 | override fun getTypeName(): String { 22 | return info.typeText 23 | } 24 | 25 | override fun init(psiElement: PsiElement) {} 26 | 27 | override fun isRequired(): Boolean = false 28 | 29 | override fun hasIdType(): Boolean { 30 | return name == "id" 31 | } 32 | 33 | override fun hasIdRefType(): Boolean = false 34 | 35 | override fun isEnumerated(): Boolean { 36 | return !info.hasValue() 37 | } 38 | 39 | override fun getDeclaration(): PsiElement? = xmlTag 40 | 41 | override fun getName(): String = name 42 | 43 | override fun getDependencies(): Array = ArrayUtil.EMPTY_OBJECT_ARRAY 44 | 45 | override fun isFixed(): Boolean = false 46 | 47 | override fun getDefaultValue(): String? = null 48 | 49 | override fun getEnumeratedValues(): Array? = ArrayUtil.EMPTY_STRING_ARRAY 50 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/core/AlpineLineMarkerProvider.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.core 2 | 3 | import com.github.inxilpro.intellijalpine.Alpine 4 | import com.github.inxilpro.intellijalpine.attributes.AlpineAttributeDescriptor 5 | import com.github.inxilpro.intellijalpine.settings.AlpineSettingsState 6 | import com.intellij.codeInsight.daemon.RelatedItemLineMarkerInfo 7 | import com.intellij.codeInsight.daemon.RelatedItemLineMarkerProvider 8 | import com.intellij.codeInsight.navigation.NavigationGutterIconBuilder 9 | import com.intellij.psi.PsiElement 10 | import com.intellij.psi.impl.source.xml.XmlTokenImpl 11 | import com.intellij.psi.util.PsiTreeUtil 12 | import com.intellij.psi.xml.XmlAttribute 13 | import javax.swing.Icon 14 | 15 | class AlpineLineMarkerProvider : RelatedItemLineMarkerProvider() { 16 | override fun getIcon(): Icon { 17 | return Alpine.ICON 18 | } 19 | 20 | override fun collectNavigationMarkers( 21 | element: PsiElement, 22 | result: MutableCollection?> 23 | ) { 24 | if (!AlpineSettingsState.instance.showGutterIcons) return 25 | 26 | if (element is XmlAttribute && element.descriptor is AlpineAttributeDescriptor) { 27 | 28 | val token = PsiTreeUtil.getChildOfType(element, XmlTokenImpl::class.java) ?: return 29 | 30 | val builder = NavigationGutterIconBuilder.create(Alpine.ICON) 31 | .setTarget(token) 32 | .setTooltipText("Alpine.js directive") 33 | 34 | result.add(builder.createLineMarkerInfo(token)) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/PackageJsonDetector.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.core.detection 2 | 3 | import com.github.inxilpro.intellijalpine.core.AlpinePlugin 4 | import com.intellij.json.psi.JsonFile 5 | import com.intellij.json.psi.JsonObject 6 | import com.intellij.openapi.project.Project 7 | import com.intellij.psi.PsiManager 8 | import com.intellij.psi.search.FilenameIndex 9 | import com.intellij.psi.search.GlobalSearchScope 10 | 11 | class PackageJsonDetector : DetectionStrategy { 12 | override fun detect(project: Project, plugin: AlpinePlugin): Boolean { 13 | val packageJsonFiles = FilenameIndex.getVirtualFilesByName( 14 | "package.json", 15 | GlobalSearchScope.projectScope(project) 16 | ) 17 | 18 | return packageJsonFiles.any { virtualFile -> 19 | val psiFile = PsiManager.getInstance(project).findFile(virtualFile) 20 | if (psiFile is JsonFile) { 21 | val rootObject = psiFile.topLevelValue as? JsonObject 22 | val dependencies = rootObject?.findProperty("dependencies")?.value as? JsonObject 23 | val devDependencies = rootObject?.findProperty("devDependencies")?.value as? JsonObject 24 | 25 | hasPluginDependency(plugin, dependencies) || hasPluginDependency(plugin, devDependencies) 26 | } else { 27 | false 28 | } 29 | } 30 | } 31 | 32 | private fun hasPluginDependency(plugin: AlpinePlugin, dependencies: JsonObject?): Boolean { 33 | return plugin.getPackageNamesForDetection().any { packageName -> 34 | dependencies?.findProperty(packageName) != null 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # IntelliJ Platform Artifacts Repositories -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html 2 | 3 | pluginGroup = com.github.inxilpro.intellijalpine 4 | pluginName = Alpine.js Support 5 | pluginRepositoryUrl = https://github.com/inxilpro/IntellijAlpine 6 | # SemVer format -> https://semver.org 7 | pluginVersion = 0.7.0 8 | 9 | # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html 10 | pluginSinceBuild = 251 11 | pluginUntilBuild = 253.* 12 | 13 | # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension 14 | platformType = IU 15 | platformVersion = 2025.1 16 | 17 | # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html 18 | # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP 19 | platformPlugins = 20 | # Example: platformBundledPlugins = com.intellij.java 21 | platformBundledPlugins = JavaScript,HtmlTools 22 | 23 | # Gradle Releases -> https://github.com/gradle/gradle/releases 24 | gradleVersion = 8.13 25 | 26 | # Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib 27 | kotlin.stdlib.default.dependency = false 28 | 29 | # Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html 30 | org.gradle.configuration-cache = true 31 | 32 | # Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html 33 | org.gradle.caching = true 34 | 35 | # Enable Gradle Kotlin DSL Lazy Property Assignment -> https://docs.gradle.org/current/userguide/kotlin_dsl.html#kotdsl:assignment 36 | systemProp.org.gradle.unsafe.kotlin.assignment = true 37 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineMergeValueCompletionProvider.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.plugins 2 | 3 | import com.github.inxilpro.intellijalpine.Alpine 4 | import com.github.inxilpro.intellijalpine.completion.AlpinePluginCompletionProvider 5 | import com.github.inxilpro.intellijalpine.core.AlpinePlugin 6 | import com.intellij.codeInsight.completion.CompletionParameters 7 | import com.intellij.codeInsight.completion.CompletionResultSet 8 | import com.intellij.codeInsight.lookup.LookupElementBuilder 9 | import com.intellij.psi.util.PsiTreeUtil 10 | import com.intellij.psi.xml.XmlAttribute 11 | import com.intellij.util.ProcessingContext 12 | 13 | class AlpineMergeValueCompletionProvider( 14 | plugin: AlpinePlugin 15 | ) : AlpinePluginCompletionProvider(plugin) { 16 | 17 | private val mergeStrategies = arrayOf( 18 | "before" to "Insert content before target", 19 | "replace" to "Replace target element (default)", 20 | "update" to "Update target's innerHTML", 21 | "prepend" to "Prepend content to target", 22 | "append" to "Append content to target", 23 | "after" to "Insert content after target", 24 | "morph" to "Morph content preserving state" 25 | ) 26 | 27 | override fun addPluginCompletions( 28 | parameters: CompletionParameters, 29 | context: ProcessingContext, 30 | result: CompletionResultSet 31 | ) { 32 | val element = parameters.position 33 | val attribute = PsiTreeUtil.getParentOfType(element, XmlAttribute::class.java) ?: return 34 | 35 | if (attribute.name != "x-merge") { 36 | return 37 | } 38 | 39 | for ((strategy, description) in mergeStrategies) { 40 | val lookupElement = LookupElementBuilder 41 | .create(strategy) 42 | .withTypeText(description) 43 | .withIcon(Alpine.ICON) 44 | 45 | result.addElement(lookupElement) 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /.github/workflows/run-ui-tests.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow for launching UI tests on Linux, Windows, and Mac in the following steps: 2 | # - prepare and launch IDE with your plugin and robot-server plugin, which is needed to interact with UI 3 | # - wait for IDE to start 4 | # - run UI tests with separate Gradle task 5 | # 6 | # Please check https://github.com/JetBrains/intellij-ui-test-robot for information about UI tests with IntelliJ Platform 7 | # 8 | # Workflow is triggered manually. 9 | 10 | name: Run UI Tests 11 | on: 12 | workflow_dispatch 13 | 14 | jobs: 15 | 16 | testUI: 17 | runs-on: ${{ matrix.os }} 18 | timeout-minutes: 45 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | include: 23 | - os: ubuntu-latest 24 | runIde: | 25 | export DISPLAY=:99.0 26 | Xvfb -ac :99 -screen 0 1920x1080x16 & 27 | gradle runIdeForUiTests & 28 | - os: windows-latest 29 | runIde: start gradlew.bat runIdeForUiTests 30 | - os: macos-latest 31 | runIde: ./gradlew runIdeForUiTests & 32 | 33 | steps: 34 | 35 | # Check out current repository 36 | - name: Fetch Sources 37 | uses: actions/checkout@v4 38 | 39 | # Setup Java environment for the next steps 40 | - name: Setup Java 41 | uses: actions/setup-java@v4 42 | with: 43 | distribution: zulu 44 | java-version: 17 45 | 46 | # Setup Gradle 47 | - name: Setup Gradle 48 | uses: gradle/gradle-build-action@v3 49 | with: 50 | gradle-home-cache-cleanup: true 51 | 52 | # Run IDEA prepared for UI testing 53 | - name: Run IDE 54 | run: ${{ matrix.runIde }} 55 | 56 | # Wait for IDEA to be started 57 | - name: Health Check 58 | uses: jtalk/url-health-check-action@v4 59 | with: 60 | url: http://127.0.0.1:8082 61 | max-attempts: 15 62 | retry-delay: 30s 63 | 64 | # Run tests 65 | - name: Tests 66 | run: ./gradlew test 67 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/TooltipPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.plugins 2 | 3 | import com.github.inxilpro.intellijalpine.attributes.AttributeInfo 4 | import com.github.inxilpro.intellijalpine.completion.AutoCompleteSuggestions 5 | import com.github.inxilpro.intellijalpine.core.AlpinePlugin 6 | import org.apache.commons.lang3.tuple.MutablePair 7 | 8 | class TooltipPlugin : AlpinePlugin { 9 | 10 | override fun getPluginName(): String = "alpine-tooltip" 11 | 12 | override fun getPackageDisplayName(): String = "alpine-tooltip" 13 | 14 | override fun getPackageNamesForDetection(): List = listOf( 15 | "alpine-tooltip", 16 | "@ryangjchandler/alpine-tooltip" 17 | ) 18 | 19 | override fun injectAutoCompleteSuggestions(suggestions: AutoCompleteSuggestions) { 20 | val modifiers = arrayOf( 21 | "duration", 22 | "delay", 23 | "cursor", 24 | "on", 25 | "arrowless", 26 | "html", 27 | "interactive", 28 | "border", 29 | "debounce", 30 | "max-width", 31 | "theme", 32 | "placement", 33 | "animation", 34 | "no-flip", 35 | ) 36 | 37 | suggestions.addModifiers("x-tooltip", modifiers) 38 | } 39 | 40 | override fun getTypeText(info: AttributeInfo): String? { 41 | return when (info.attribute) { 42 | "x-tooltip" -> "Tippy.js tooltip" 43 | else -> null 44 | } 45 | } 46 | 47 | override fun injectJsContext(context: MutablePair): MutablePair { 48 | val magics = """ 49 | /** 50 | * @param {string} value 51 | * @param {Object} options 52 | * @return {Promise} 53 | */ 54 | function ${'$'}tooltip(value, options = {}) {} 55 | 56 | """.trimIndent() 57 | 58 | return MutablePair(context.left + magics, context.right) 59 | } 60 | 61 | override fun getDirectives(): List = listOf( 62 | "x-tooltip", 63 | ) 64 | } -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Code Guidelines 6 | 7 | - Always use modern idiomatic Kotlin code 8 | - When implementing singletons, prefer `Foo.instance` over `Foo.Companion.instance` or `Foo.getInstance()` 9 | - Only add docblocks and comments when they provide substantive value. Comments should always explain "why" not "what." 10 | 11 | ## Commands 12 | 13 | ### Building & Running 14 | - `./gradlew build` - Build the plugin 15 | - `./gradlew buildPlugin` - Assemble plugin ZIP for deployment 16 | - `./gradlew runIde` - Run IntelliJ IDEA with the plugin installed for testing 17 | - `./gradlew runIdeForUiTests` - Run IDE with robot-server for UI testing 18 | 19 | ### Testing & Verification 20 | - `./gradlew test` - Run unit tests 21 | - `./gradlew check` - Run all checks (tests + verification) 22 | - `./gradlew verifyPlugin` - Validate plugin structure and descriptors 23 | - `./gradlew runPluginVerifier` - Check binary compatibility with target IDEs 24 | - `./gradlew runInspections` - Run Qodana code inspections 25 | - `./gradlew koverReport` - Generate code coverage reports 26 | 27 | ## Architecture 28 | 29 | This is an IntelliJ IDEA plugin that adds Alpine.js support. The plugin provides: 30 | 31 | - Auto-completion for Alpine directives (x-data, x-show, x-model, etc.) 32 | - JavaScript language injection in Alpine attributes 33 | - Syntax highlighting within Alpine directives 34 | - Plugin support for third-party alpine plugins 35 | 36 | ### Plugin Configuration 37 | 38 | The plugin is configured via: 39 | 40 | - `plugin.xml` - Main plugin manifest defining extensions and dependencies 41 | - `gradle.properties` - Version and platform configuration 42 | - `build.gradle.kts` - Build configuration and dependencies 43 | 44 | The plugin requires: 45 | 46 | - IntelliJ IDEA 2025.1 or newer 47 | - JavaScript and HtmlTools plugins as dependencies 48 | - Java 21 runtime 49 | 50 | ### Release Process 51 | 52 | 1. Update version in `gradle.properties` 53 | 2. Update `CHANGELOG.md` Unreleased section with version number 54 | 3. Push changes to main branch 55 | 4. Create and publish a GitHub release - this triggers automatic publishing to JetBrains Marketplace -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/support/LanguageUtil.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.support 2 | 3 | import com.intellij.lang.Language 4 | import com.intellij.lang.html.HTMLLanguage 5 | import com.intellij.lang.xml.XMLLanguage 6 | import com.intellij.psi.PsiFile 7 | import com.intellij.psi.templateLanguages.TemplateLanguageFileViewProvider 8 | 9 | object LanguageUtil { 10 | 11 | private val HTML_LIKE_EXTENSIONS = setOf( 12 | "html", "htm", "xhtml", "xml", 13 | "php", "twig", "smarty", "tpl", "phtml", 14 | "erb", "jsp", "jsf", "ftl", "vm", 15 | ) 16 | 17 | private val TEMPLATE_LANGUAGE_IDS = setOf( 18 | "Blade", "PHP", "Twig", "Smarty", "FreeMarker", 19 | "Velocity", "JSP", "ERB", 20 | ) 21 | 22 | fun supportsAlpineJs(file: PsiFile): Boolean { 23 | return hasHtmlBasedLanguage(file) || hasTemplateLanguage(file) || hasHtmlLikeExtension(file) || isTemplateLanguageFile( 24 | file 25 | ) 26 | } 27 | 28 | fun hasPhpLanguage(file: PsiFile): Boolean { 29 | return file.viewProvider.languages.any { lang -> 30 | lang.id == "PHP" || lang.id == "Blade" 31 | } 32 | } 33 | 34 | private fun hasHtmlBasedLanguage(file: PsiFile): Boolean { 35 | return file.viewProvider.languages.any { lang -> 36 | isHtmlBasedLanguage(lang) 37 | } 38 | } 39 | 40 | private fun isHtmlBasedLanguage(language: Language): Boolean { 41 | if (language.isKindOf(HTMLLanguage.INSTANCE)) { 42 | return true 43 | } 44 | 45 | if (language.isKindOf(XMLLanguage.INSTANCE)) { 46 | return TEMPLATE_LANGUAGE_IDS.contains(language.id) 47 | } 48 | 49 | return false 50 | } 51 | 52 | private fun hasTemplateLanguage(file: PsiFile): Boolean { 53 | return file.viewProvider.languages.any { lang -> 54 | TEMPLATE_LANGUAGE_IDS.contains(lang.id) 55 | } 56 | } 57 | 58 | private fun hasHtmlLikeExtension(file: PsiFile): Boolean { 59 | val fileName = file.name.lowercase() 60 | return HTML_LIKE_EXTENSIONS.any { ext -> 61 | fileName.endsWith(".$ext") 62 | } 63 | } 64 | 65 | private fun isTemplateLanguageFile(file: PsiFile): Boolean { 66 | return file.viewProvider is TemplateLanguageFileViewProvider 67 | } 68 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/completion/AlpineAttributeCompletionProvider.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.completion 2 | 3 | import com.github.inxilpro.intellijalpine.Alpine 4 | import com.github.inxilpro.intellijalpine.support.LanguageUtil 5 | import com.intellij.codeInsight.completion.CompletionParameters 6 | import com.intellij.codeInsight.completion.CompletionProvider 7 | import com.intellij.codeInsight.completion.CompletionResultSet 8 | import com.intellij.codeInsight.completion.CompletionUtilCore 9 | import com.intellij.codeInsight.completion.XmlAttributeInsertHandler 10 | import com.intellij.codeInsight.lookup.LookupElementBuilder 11 | import com.intellij.openapi.util.text.StringUtil 12 | import com.intellij.psi.html.HtmlTag 13 | import com.intellij.psi.xml.XmlAttribute 14 | import com.intellij.util.ProcessingContext 15 | 16 | class AlpineAttributeCompletionProvider(vararg items: String) : CompletionProvider() { 17 | 18 | @Suppress("ReturnCount") 19 | public override fun addCompletions( 20 | parameters: CompletionParameters, 21 | context: ProcessingContext, 22 | result: CompletionResultSet 23 | ) { 24 | val position = parameters.position 25 | 26 | if (!LanguageUtil.supportsAlpineJs(position.containingFile)) { 27 | return 28 | } 29 | 30 | val attribute = position.parent as? XmlAttribute ?: return 31 | val xmlTag = attribute.parent as? HtmlTag ?: return 32 | 33 | val partialAttribute = StringUtil.trimEnd(attribute.name, CompletionUtilCore.DUMMY_IDENTIFIER_TRIMMED) 34 | 35 | if (partialAttribute.isEmpty()) { 36 | return 37 | } 38 | 39 | val suggestions = AutoCompleteSuggestions(xmlTag, partialAttribute) 40 | 41 | suggestions.descriptors.forEach { 42 | var text = it.attribute 43 | 44 | // If you go back and add a modifier, it ignores the prefix, so we'll 45 | // just kinda code around that for now 46 | if (text.contains(':') && text.contains('.')) { 47 | text = text.substringAfter(':') 48 | } 49 | 50 | var elementBuilder = LookupElementBuilder 51 | .create(text) 52 | .withCaseSensitivity(false) 53 | .withIcon(Alpine.ICON) 54 | .withTypeText(it.typeText) 55 | 56 | if (it.hasValue() && !it.canBePrefix()) { 57 | elementBuilder = elementBuilder.withInsertHandler(XmlAttributeInsertHandler.INSTANCE) 58 | } 59 | 60 | result.addElement(elementBuilder) 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsComponent.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.settings 2 | 3 | import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry 4 | import com.intellij.openapi.project.Project 5 | import com.intellij.ui.TitledSeparator 6 | import com.intellij.ui.components.JBCheckBox 7 | import com.intellij.ui.components.JBLabel 8 | import com.intellij.util.ui.FormBuilder 9 | import com.intellij.util.ui.UIUtil 10 | import javax.swing.JComponent 11 | import javax.swing.JPanel 12 | 13 | class AlpineSettingsComponent(project: Project?) { 14 | val panel: JPanel 15 | 16 | private val myShowGutterIconsStatus = JBCheckBox("Show Alpine gutter icons") 17 | private val pluginCheckBoxes = mutableMapOf() 18 | 19 | val preferredFocusedComponent: JComponent 20 | get() = myShowGutterIconsStatus 21 | 22 | var showGutterIconsStatus: Boolean 23 | get() = myShowGutterIconsStatus.isSelected 24 | set(newStatus) { 25 | myShowGutterIconsStatus.isSelected = newStatus 26 | } 27 | 28 | fun getPluginStatus(pluginName: String): Boolean { 29 | return pluginCheckBoxes[pluginName]?.isSelected ?: false 30 | } 31 | 32 | fun setPluginStatus(pluginName: String, enabled: Boolean) { 33 | pluginCheckBoxes[pluginName]?.isSelected = enabled 34 | } 35 | 36 | init { 37 | val builder = FormBuilder.createFormBuilder() 38 | .addComponent(TitledSeparator("Plugin Settings")) 39 | .addComponent(myShowGutterIconsStatus, 1) 40 | 41 | // Only show project settings if we have a project context 42 | if (project != null) { 43 | builder.addVerticalGap(10) // Add spacing between sections 44 | .addComponent(TitledSeparator("Project Settings for “${project.name}”")) 45 | 46 | val projectLabel = JBLabel("These settings apply only to the current project") 47 | projectLabel.foreground = UIUtil.getContextHelpForeground() 48 | projectLabel.font = UIUtil.getLabelFont(UIUtil.FontSize.SMALL) 49 | builder.addComponent(projectLabel, 1) 50 | .addVerticalGap(5) 51 | 52 | // Dynamically add checkboxes for each registered plugin 53 | AlpinePluginRegistry.instance.getRegisteredPlugins().forEach { plugin -> 54 | val checkBox = JBCheckBox("Enable “${plugin.getPackageDisplayName()}” support for this project") 55 | pluginCheckBoxes[plugin.getPluginName()] = checkBox 56 | builder.addComponent(checkBox, 1) 57 | } 58 | } 59 | 60 | panel = builder.addComponentFillVertically(JPanel(), 0).panel 61 | } 62 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsConfigurable.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.settings 2 | 3 | import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry 4 | import com.intellij.openapi.options.Configurable 5 | import com.intellij.openapi.project.Project 6 | import javax.swing.JComponent 7 | 8 | class AlpineSettingsConfigurable(private val project: Project?) : Configurable { 9 | private var mySettingsComponent: AlpineSettingsComponent? = null 10 | 11 | @Suppress("DialogTitleCapitalization") 12 | override fun getDisplayName(): String { 13 | return "Alpine.js" 14 | } 15 | 16 | override fun getPreferredFocusedComponent(): JComponent? { 17 | return mySettingsComponent?.preferredFocusedComponent 18 | } 19 | 20 | override fun createComponent(): JComponent? { 21 | mySettingsComponent = AlpineSettingsComponent(project) 22 | return mySettingsComponent?.panel 23 | } 24 | 25 | override fun isModified(): Boolean { 26 | val appSettings = AlpineSettingsState.instance 27 | var isModified = mySettingsComponent?.showGutterIconsStatus != appSettings.showGutterIcons 28 | 29 | // Check project settings if we have a project 30 | if (project != null) { 31 | val registry = AlpinePluginRegistry.instance 32 | registry.getRegisteredPlugins().forEach { plugin -> 33 | val pluginName = plugin.getPluginName() 34 | val currentStatus = mySettingsComponent?.getPluginStatus(pluginName) ?: false 35 | val savedStatus = registry.isPluginEnabled(project, pluginName) 36 | if (currentStatus != savedStatus) { 37 | isModified = true 38 | } 39 | } 40 | } 41 | 42 | return isModified 43 | } 44 | 45 | override fun apply() { 46 | val appSettings = AlpineSettingsState.instance 47 | appSettings.showGutterIcons = mySettingsComponent?.showGutterIconsStatus != false 48 | 49 | // Apply project settings if we have a project 50 | if (project != null) { 51 | val registry = AlpinePluginRegistry.instance 52 | registry.getRegisteredPlugins().forEach { plugin -> 53 | val pluginName = plugin.getPluginName() 54 | val enabled = mySettingsComponent?.getPluginStatus(pluginName) ?: false 55 | if (enabled) { 56 | registry.enablePlugin(project, pluginName) 57 | } else { 58 | registry.disablePlugin(project, pluginName) 59 | } 60 | } 61 | } 62 | } 63 | 64 | override fun reset() { 65 | val appSettings = AlpineSettingsState.instance 66 | mySettingsComponent?.showGutterIconsStatus = appSettings.showGutterIcons 67 | 68 | // Reset project settings if we have a project 69 | if (project != null) { 70 | val registry = AlpinePluginRegistry.instance 71 | registry.getRegisteredPlugins().forEach { plugin -> 72 | val pluginName = plugin.getPluginName() 73 | val enabled = registry.isPluginEnabled(project, pluginName) 74 | mySettingsComponent?.setPluginStatus(pluginName, enabled) 75 | } 76 | } 77 | } 78 | 79 | override fun disposeUIResources() { 80 | mySettingsComponent = null 81 | } 82 | } -------------------------------------------------------------------------------- /src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | com.github.inxilpro.intellijalpine 4 | 5 | Alpine.js Support 6 | 7 | 8 | Chris Morrell 9 | 10 | 11 | 12 | 13 | com.intellij.modules.platform 14 | com.intellij.modules.lang 15 | com.intellij.modules.xml 16 | JavaScript 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 28 | 29 | 30 | 33 | 35 | 37 | 38 | 39 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow created for handling the release process based on the draft release prepared with the Build workflow. 2 | # Running the publishPlugin task requires all following secrets to be provided: PUBLISH_TOKEN, PRIVATE_KEY, PRIVATE_KEY_PASSWORD, CERTIFICATE_CHAIN. 3 | # See https://plugins.jetbrains.com/docs/intellij/plugin-signing.html for more information. 4 | 5 | name: Release 6 | on: 7 | release: 8 | types: [prereleased, released] 9 | 10 | jobs: 11 | 12 | # Prepare and publish the plugin to the Marketplace repository 13 | release: 14 | name: Publish Plugin 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 30 17 | permissions: 18 | contents: write 19 | pull-requests: write 20 | steps: 21 | 22 | # Check out current repository 23 | - name: Fetch Sources 24 | uses: actions/checkout@v4 25 | with: 26 | ref: ${{ github.event.release.tag_name }} 27 | 28 | # Setup Java environment for the next steps 29 | - name: Setup Java 30 | uses: actions/setup-java@v4 31 | with: 32 | distribution: zulu 33 | java-version: 17 34 | 35 | # Setup Gradle 36 | - name: Setup Gradle 37 | uses: gradle/gradle-build-action@v3 38 | with: 39 | gradle-home-cache-cleanup: true 40 | 41 | # Set environment variables 42 | - name: Export Properties 43 | id: properties 44 | shell: bash 45 | run: | 46 | CHANGELOG="$(cat << 'EOM' | sed -e 's/^[[:space:]]*$//g' -e '/./,$!d' 47 | ${{ github.event.release.body }} 48 | EOM 49 | )" 50 | 51 | echo "changelog<> $GITHUB_OUTPUT 52 | echo "$CHANGELOG" >> $GITHUB_OUTPUT 53 | echo "EOF" >> $GITHUB_OUTPUT 54 | 55 | # Update Unreleased section with the current release note 56 | - name: Patch Changelog 57 | if: ${{ steps.properties.outputs.changelog != '' }} 58 | env: 59 | CHANGELOG: ${{ steps.properties.outputs.changelog }} 60 | run: | 61 | ./gradlew patchChangelog --release-note="$CHANGELOG" 62 | 63 | # Publish the plugin to the Marketplace 64 | - name: Publish Plugin 65 | env: 66 | PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} 67 | CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} 68 | PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} 69 | PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} 70 | run: ./gradlew publishPlugin 71 | 72 | # Upload artifact as a release asset 73 | - name: Upload Release Asset 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | run: gh release upload ${{ github.event.release.tag_name }} ./build/distributions/* 77 | 78 | # Create pull request 79 | - name: Create Pull Request 80 | if: ${{ steps.properties.outputs.changelog != '' }} 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 83 | run: | 84 | VERSION="${{ github.event.release.tag_name }}" 85 | BRANCH="changelog-update-$VERSION" 86 | LABEL="release changelog" 87 | 88 | git config user.email "action@github.com" 89 | git config user.name "GitHub Action" 90 | 91 | git checkout -b $BRANCH 92 | git commit -am "Changelog update - $VERSION" 93 | git push --set-upstream origin $BRANCH 94 | 95 | gh label create "$LABEL" \ 96 | --description "Pull requests with release changelog update" \ 97 | || true 98 | 99 | gh pr create \ 100 | --title "Changelog update - \`$VERSION\`" \ 101 | --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ 102 | --label "$LABEL" \ 103 | --head $BRANCH 104 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineAjaxPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.plugins 2 | 3 | import com.github.inxilpro.intellijalpine.attributes.AttributeInfo 4 | import com.github.inxilpro.intellijalpine.completion.AutoCompleteSuggestions 5 | import com.github.inxilpro.intellijalpine.core.AlpinePlugin 6 | import com.github.inxilpro.intellijalpine.core.CompletionProviderRegistration 7 | import com.intellij.patterns.XmlPatterns 8 | import com.intellij.psi.xml.XmlTokenType 9 | import org.apache.commons.lang3.tuple.MutablePair 10 | 11 | class AlpineAjaxPlugin : AlpinePlugin { 12 | 13 | val targetModifiers = arrayOf( 14 | "200", 15 | "301", 16 | "302", 17 | "303", 18 | "400", 19 | "401", 20 | "403", 21 | "404", 22 | "422", 23 | "500", 24 | "502", 25 | "503", 26 | "2xx", 27 | "3xx", 28 | "4xx", 29 | "5xx", 30 | "back", 31 | "away", 32 | "replace", 33 | "push", 34 | "error", 35 | "nofocus", 36 | ) 37 | 38 | override fun getPluginName(): String = "alpine-ajax" 39 | 40 | override fun getPackageDisplayName(): String = "alpine-ajax" 41 | 42 | override fun getPackageNamesForDetection(): List = listOf( 43 | "alpine-ajax", 44 | "@imacrayon/alpine-ajax" 45 | ) 46 | 47 | override fun getTypeText(info: AttributeInfo): String? { 48 | if ("x-target:" == info.prefix) { 49 | return "DOM node to inject response into" 50 | } 51 | 52 | return when (info.attribute) { 53 | "x-target" -> "DOM node to inject response into" 54 | "x-headers" -> "Set AJAX request headers" 55 | "x-merge" -> "Merge response data with existing data" 56 | "x-autofocus" -> "Auto-focus on AJAX response" 57 | "x-sync" -> "Always sync on AJAX response" 58 | else -> null 59 | } 60 | } 61 | 62 | override fun injectJsContext(context: MutablePair): MutablePair { 63 | val magics = """ 64 | /** 65 | * @param {string} action 66 | * @param {Object} options 67 | * @return {Promise} 68 | */ 69 | function ${'$'}ajax(action, options = {}) {} 70 | 71 | """.trimIndent() 72 | 73 | return MutablePair(context.left + magics, context.right) 74 | } 75 | 76 | override fun directiveSupportJavaScript(directive: String): Boolean { 77 | return when (directive) { 78 | "x-target", "x-autofocus", "x-sync", "x-merge" -> false 79 | else -> true 80 | } 81 | } 82 | 83 | override fun injectAutoCompleteSuggestions(suggestions: AutoCompleteSuggestions) { 84 | suggestions.descriptors.add(AttributeInfo("x-target:dynamic")) 85 | suggestions.addModifiers("x-target", targetModifiers) 86 | suggestions.addModifiers("x-target:dynamic", targetModifiers) 87 | } 88 | 89 | override fun getCompletionProviders(): List { 90 | return listOf( 91 | CompletionProviderRegistration( 92 | XmlPatterns.psiElement(XmlTokenType.XML_ATTRIBUTE_VALUE_TOKEN) 93 | .withParent( 94 | XmlPatterns.xmlAttributeValue().withParent(XmlPatterns.xmlAttribute().withName("x-merge")) 95 | ), 96 | AlpineMergeValueCompletionProvider(this) 97 | ) 98 | ) 99 | } 100 | 101 | override fun getDirectives(): List = listOf( 102 | "x-target", 103 | "x-headers", 104 | "x-merge", 105 | "x-autofocus", 106 | "x-sync" 107 | ) 108 | 109 | override fun getPrefixes(): List = listOf( 110 | "x-target" 111 | ) 112 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineTargetReferenceContributor.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.plugins 2 | 3 | import com.github.inxilpro.intellijalpine.Alpine 4 | import com.intellij.codeInsight.lookup.LookupElementBuilder 5 | import com.intellij.openapi.util.TextRange 6 | import com.intellij.patterns.XmlPatterns 7 | import com.intellij.psi.PsiElement 8 | import com.intellij.psi.PsiReference 9 | import com.intellij.psi.PsiReferenceBase 10 | import com.intellij.psi.PsiReferenceContributor 11 | import com.intellij.psi.PsiReferenceProvider 12 | import com.intellij.psi.PsiReferenceRegistrar 13 | import com.intellij.psi.util.PsiTreeUtil 14 | import com.intellij.psi.xml.XmlAttributeValue 15 | import com.intellij.psi.xml.XmlFile 16 | import com.intellij.psi.xml.XmlTag 17 | import com.intellij.util.ProcessingContext 18 | 19 | class AlpineTargetReferenceContributor : PsiReferenceContributor() { 20 | override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) { 21 | registrar.registerReferenceProvider( 22 | XmlPatterns.xmlAttributeValue().withParent( 23 | XmlPatterns.xmlAttribute().withName("x-target") 24 | ), 25 | AlpineTargetReferenceProvider() 26 | ) 27 | } 28 | } 29 | 30 | class AlpineTargetReferenceProvider : PsiReferenceProvider() { 31 | override fun getReferencesByElement( 32 | element: PsiElement, 33 | context: ProcessingContext 34 | ): Array { 35 | val attributeValue = element as? XmlAttributeValue ?: return PsiReference.EMPTY_ARRAY 36 | val value = attributeValue.value 37 | 38 | if (value.isBlank()) return PsiReference.EMPTY_ARRAY 39 | 40 | val references = mutableListOf() 41 | val ids = value.split("\\s+".toRegex()).filter { it.isNotBlank() } 42 | var searchStart = 0 43 | 44 | for (id in ids) { 45 | val startIndex = value.indexOf(id, searchStart) 46 | if (startIndex >= 0) { 47 | // Range relative to the attribute value element (includes quotes) 48 | val range = TextRange(startIndex + 1, startIndex + id.length + 1) 49 | references.add(AlpineIdReference(attributeValue, range, id)) 50 | searchStart = startIndex + id.length 51 | } 52 | } 53 | 54 | return references.toTypedArray() 55 | } 56 | } 57 | 58 | class AlpineIdReference( 59 | element: PsiElement, 60 | rangeInElement: TextRange, 61 | private val idValue: String 62 | ) : PsiReferenceBase(element, rangeInElement) { 63 | 64 | override fun resolve(): PsiElement? { 65 | val xmlFile = element.containingFile as? XmlFile ?: return null 66 | return findElementWithId(xmlFile, idValue) 67 | } 68 | 69 | override fun getVariants(): Array { 70 | val xmlFile = element.containingFile as? XmlFile ?: return emptyArray() 71 | val allIds = collectElementIds(xmlFile) 72 | val currentValue = (element as XmlAttributeValue).value 73 | val usedIds = currentValue.split("\\s+".toRegex()).filter { it.isNotBlank() }.toSet() 74 | val availableIds = allIds - usedIds 75 | 76 | return availableIds.map { id -> 77 | LookupElementBuilder.create(id) 78 | .withTypeText("Element ID") 79 | .withIcon(Alpine.ICON) 80 | }.toTypedArray() 81 | } 82 | 83 | override fun isSoft(): Boolean = false // Hard reference - should show error if unresolved 84 | 85 | private fun findElementWithId(xmlFile: XmlFile, id: String): PsiElement? { 86 | val allTags = PsiTreeUtil.findChildrenOfType(xmlFile, XmlTag::class.java) 87 | 88 | return allTags.firstOrNull { tag -> 89 | tag.getAttribute("id")?.value == id 90 | }?.getAttribute("id")?.valueElement 91 | } 92 | 93 | private fun collectElementIds(xmlFile: XmlFile): Set { 94 | val allTags = PsiTreeUtil.findChildrenOfType(xmlFile, XmlTag::class.java) 95 | 96 | return allTags.mapNotNull { tag -> 97 | tag.getAttribute("id")?.value?.takeIf { it.isNotBlank() } 98 | }.toSet() 99 | } 100 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineWizardPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.plugins 2 | 3 | import com.github.inxilpro.intellijalpine.attributes.AttributeInfo 4 | import com.github.inxilpro.intellijalpine.completion.AutoCompleteSuggestions 5 | import com.github.inxilpro.intellijalpine.core.AlpinePlugin 6 | import org.apache.commons.lang3.tuple.MutablePair 7 | 8 | class AlpineWizardPlugin : AlpinePlugin { 9 | 10 | override fun getPluginName(): String = "alpine-wizard" 11 | 12 | override fun getPackageDisplayName(): String = "alpine-wizard" 13 | 14 | override fun getPackageNamesForDetection(): List = listOf( 15 | "alpine-wizard", 16 | "@glhd/alpine-wizard" 17 | ) 18 | 19 | override fun getTypeText(info: AttributeInfo): String? { 20 | if ("x-wizard:" == info.prefix) { 21 | return when (info.name) { 22 | "step" -> "Define wizard step" 23 | "if" -> "Conditional wizard step" 24 | "title" -> "Set step title" 25 | else -> "Alpine Wizard directive" 26 | } 27 | } 28 | 29 | return when (info.attribute) { 30 | "x-wizard:step" -> "Define wizard step" 31 | "x-wizard:if" -> "Conditional wizard step" 32 | "x-wizard:title" -> "Set step title" 33 | else -> null 34 | } 35 | } 36 | 37 | override fun injectJsContext(context: MutablePair): MutablePair { 38 | val wizardMagics = """ 39 | class AlpineWizardStep { 40 | /** @type {HTMLElement} */ el; 41 | /** @type {string} */ title; 42 | /** @type {boolean} */ is_applicable; 43 | /** @type {boolean} */ is_complete; 44 | } 45 | 46 | class AlpineWizardProgress { 47 | /** @type {number} */ current; 48 | /** @type {number} */ total; 49 | /** @type {number} */ complete; 50 | /** @type {number} */ incomplete; 51 | /** @type {string} */ percentage; 52 | /** @type {number} */ percentage_int; 53 | /** @type {number} */ percentage_float; 54 | } 55 | 56 | class AlpineWizardMagic { 57 | /** @returns {AlpineWizardStep} */ current() {} 58 | /** @returns {AlpineWizardStep|null} */ next() {} 59 | /** @returns {AlpineWizardStep|null} */ previous() {} 60 | /** @returns {AlpineWizardProgress} */ progress() {} 61 | /** @returns {boolean} */ isFirst() {} 62 | /** @returns {boolean} */ isNotFirst() {} 63 | /** @returns {boolean} */ isLast() {} 64 | /** @returns {boolean} */ isNotLast() {} 65 | /** @returns {boolean} */ isComplete() {} 66 | /** @returns {boolean} */ isNotComplete() {} 67 | /** @returns {boolean} */ isIncomplete() {} 68 | /** @returns {boolean} */ canGoForward() {} 69 | /** @returns {boolean} */ cannotGoForward() {} 70 | /** @returns {boolean} */ canGoBack() {} 71 | /** @returns {boolean} */ cannotGoBack() {} 72 | /** @returns {void} */ forward() {} 73 | /** @returns {void} */ back() {} 74 | } 75 | 76 | /** @type {AlpineWizardMagic} */ 77 | let ${'$'}wizard; 78 | 79 | """.trimIndent() 80 | 81 | return MutablePair(context.left + wizardMagics, context.right) 82 | } 83 | 84 | override fun injectAutoCompleteSuggestions(suggestions: AutoCompleteSuggestions) { 85 | suggestions.descriptors.add(AttributeInfo("x-wizard:step")) 86 | suggestions.addModifiers("x-wizard:step", arrayOf("rules")) 87 | 88 | suggestions.descriptors.add(AttributeInfo("x-wizard:if")) 89 | suggestions.descriptors.add(AttributeInfo("x-wizard:title")) 90 | } 91 | 92 | override fun getDirectives(): List = listOf( 93 | "x-wizard:step", 94 | "x-wizard:if", 95 | "x-wizard:title" 96 | ) 97 | 98 | override fun getPrefixes(): List = listOf( 99 | "x-wizard" 100 | ) 101 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/attributes/AttributeInfo.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.attributes 2 | 3 | import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry 4 | 5 | @Suppress("MemberVisibilityCanBePrivate") 6 | class AttributeInfo(val attribute: String) { 7 | 8 | private val typeTexts = hashMapOf( 9 | "x-data" to "New Alpine.js component scope", 10 | "x-init" to "Run on initialization", 11 | "x-show" to "Toggles 'display: none'", 12 | "x-model" to "Add two-way binding", 13 | "x-modelable" to "Expose x-model target", 14 | "x-text" to "Bind to element's inner text", 15 | "x-html" to "Bind to element's inner HTML", 16 | "x-ref" to "Create a reference for later use", 17 | "x-if" to "Conditionally render template", 18 | "x-id" to "Register \$id() scope", 19 | "x-for" to "Map array to DOM nodes", 20 | "x-transition" to "Add transition classes", 21 | "x-transition:enter" to "Transition classes used during the entire entering phase", 22 | "x-transition:enter-start" to "Transition classes for start of entering phase", 23 | "x-transition:enter-end" to "Transition classes end of entering phase", 24 | "x-transition:leave" to "Transition classes used during the entire leaving phase", 25 | "x-transition:leave-start" to "Transition classes for start of leaving phase", 26 | "x-transition:leave-end" to "Transition classes for end of leaving phase", 27 | "x-effect" to "Add reactive effect", 28 | "x-ignore" to "Ignore DOM node in Alpine.js", 29 | "x-spread" to "Bind reusable directives", 30 | "x-cloak" to "Hide while Alpine is initializing", 31 | "x-teleport" to "Teleport template to another DOM node", 32 | "x-on" to "Add listener", 33 | "x-bind" to "Bind an attribute", 34 | "x-mask" to "Set input mask", 35 | "x-intersect" to "Bind an intersection observer", 36 | "x-trap" to "Add focus trap", 37 | "x-collapse" to "Collapse element when hidden", 38 | ) 39 | 40 | val name: String 41 | 42 | val prefix: String 43 | 44 | val typeText: String 45 | 46 | init { 47 | prefix = extractPrefix() 48 | name = attribute.substring(prefix.length).substringBefore('.') 49 | typeText = buildTypeText() 50 | } 51 | 52 | @Suppress("ComplexCondition") 53 | fun isAlpine(): Boolean = isDirective() || isPrefixed() || canBePrefix() 54 | 55 | fun isEvent(): Boolean = "@" == prefix || "x-on:" == prefix 56 | 57 | fun isBound(): Boolean = ":" == prefix || "x-bind:" == prefix 58 | 59 | fun isTransition(): Boolean = "x-transition:" == prefix 60 | 61 | fun isDirective(): Boolean = AttributeUtil.directives.contains(name) 62 | 63 | fun hasValue(): Boolean = "x-cloak" != name && "x-ignore" != name 64 | 65 | fun canBePrefix(): Boolean = AttributeUtil.prefixes.contains(name) 66 | 67 | fun isPrefixed(): Boolean = prefix != "" 68 | 69 | @Suppress("ReturnCount") 70 | private fun extractPrefix(): String { 71 | for (prefix in AttributeUtil.prefixes) { 72 | if (attribute.startsWith("$prefix:")) { 73 | return "$prefix:" 74 | } 75 | } 76 | 77 | for (eventPrefix in AttributeUtil.eventPrefixes) { 78 | if (attribute.startsWith(eventPrefix)) { 79 | return eventPrefix 80 | } 81 | } 82 | 83 | for (bindPrefix in AttributeUtil.bindPrefixes) { 84 | if (attribute.startsWith(bindPrefix)) { 85 | return bindPrefix 86 | } 87 | } 88 | 89 | return "" 90 | } 91 | 92 | @Suppress("ReturnCount") 93 | private fun buildTypeText(): String { 94 | if (isEvent()) { 95 | return "'$name' listener" 96 | } 97 | 98 | if (isBound()) { 99 | return "Bind '$name' attribute" 100 | } 101 | 102 | if (isTransition()) { 103 | return "CSS classes for '$name' transition phase" 104 | } 105 | 106 | // First check plugin registry for type text 107 | val pluginTypeText = AlpinePluginRegistry.instance.getTypeText(this) 108 | if (pluginTypeText != null) { 109 | return pluginTypeText 110 | } 111 | 112 | return typeTexts.getOrDefault(attribute, "Alpine.js") 113 | } 114 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/ScriptReferenceDetector.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.core.detection 2 | 3 | import com.github.inxilpro.intellijalpine.core.AlpinePlugin 4 | import com.intellij.openapi.project.Project 5 | import com.intellij.openapi.vfs.VirtualFile 6 | import com.intellij.psi.PsiManager 7 | import com.intellij.psi.search.FilenameIndex 8 | import com.intellij.psi.search.GlobalSearchScope 9 | import com.intellij.psi.util.PsiTreeUtil 10 | import com.intellij.psi.xml.XmlFile 11 | import com.intellij.psi.xml.XmlTag 12 | 13 | class ScriptReferenceDetector : DetectionStrategy { 14 | override fun detect(project: Project, plugin: AlpinePlugin): Boolean { 15 | return hasScriptTagReferences(project, plugin) || hasImportReferences(project, plugin) 16 | } 17 | 18 | private fun hasScriptTagReferences(project: Project, plugin: AlpinePlugin): Boolean { 19 | val htmlFiles = mutableListOf() 20 | val extensions = listOf("html", "htm", "php", "twig", "djhtml", "jinja", "astro") 21 | 22 | for (extension in extensions) { 23 | htmlFiles.addAll( 24 | FilenameIndex.getAllFilesByExt(project, extension, GlobalSearchScope.projectScope(project)) 25 | ) 26 | } 27 | 28 | return htmlFiles.any { virtualFile -> 29 | val psiFile = PsiManager.getInstance(project).findFile(virtualFile) 30 | 31 | when { 32 | // If the IDE knows it's XML, use structured data 33 | psiFile is XmlFile -> PsiTreeUtil.collectElementsOfType(psiFile, XmlTag::class.java) 34 | .filter { it.name.equals("script", ignoreCase = true) } 35 | .any { scriptTag -> 36 | val src = scriptTag.getAttributeValue("src") 37 | src != null && containsPackageReference(src, plugin) 38 | } 39 | 40 | // Otherwise, just look at the contents of the file 41 | else -> { 42 | try { 43 | val content = String(virtualFile.contentsToByteArray()) 44 | hasScriptTagsInContent(content, plugin) 45 | } catch (_: Exception) { 46 | false 47 | } 48 | } 49 | } 50 | } 51 | } 52 | 53 | private fun hasImportReferences(project: Project, plugin: AlpinePlugin): Boolean { 54 | val jsExtensions = listOf("js", "ts", "mjs") 55 | val jsFiles = mutableListOf() 56 | 57 | for (extension in jsExtensions) { 58 | jsFiles.addAll( 59 | FilenameIndex.getAllFilesByExt(project, extension, GlobalSearchScope.projectScope(project)) 60 | ) 61 | } 62 | 63 | return jsFiles.any { virtualFile -> 64 | try { 65 | val content = String(virtualFile.contentsToByteArray()) 66 | hasImportStatements(content, plugin) 67 | } catch (_: Exception) { 68 | false 69 | } 70 | } 71 | } 72 | 73 | private fun hasScriptTagsInContent(content: String, plugin: AlpinePlugin): Boolean { 74 | val scriptTagRegex = Regex("]*src=['\"]([^'\"]*)['\"][^>]*>", RegexOption.IGNORE_CASE) 75 | return scriptTagRegex.findAll(content).any { match -> 76 | val src = match.groupValues[1] 77 | containsPackageReference(src, plugin) 78 | } 79 | } 80 | 81 | private fun hasImportStatements(content: String, plugin: AlpinePlugin): Boolean { 82 | val importPatterns = plugin.getPackageNamesForDetection().flatMap { packageName -> 83 | val escapedPackageName = Regex.escape(packageName) 84 | listOf( 85 | "import\\s+.*\\s+from\\s+['\"]$escapedPackageName['\"]", 86 | "import\\s+['\"]$escapedPackageName['\"]", 87 | "require\\s*\\(\\s*['\"]$escapedPackageName['\"]\\s*\\)", 88 | "from\\s+['\"]$escapedPackageName['\"]\\s+import" 89 | ) 90 | } 91 | 92 | return importPatterns.any { pattern -> 93 | Regex(pattern, RegexOption.IGNORE_CASE).containsMatchIn(content) 94 | } 95 | } 96 | 97 | private fun containsPackageReference(src: String, plugin: AlpinePlugin): Boolean { 98 | return plugin.getPackageNamesForDetection().any { packageName -> 99 | src.contains(packageName, ignoreCase = true) 100 | } 101 | } 102 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IntellijAlpine 2 | 3 | ![Build](https://github.com/inxilpro/IntellijAlpine/workflows/Build/badge.svg) 4 | [![Version](https://img.shields.io/jetbrains/plugin/v/15251-alpine-js-support.svg)](https://plugins.jetbrains.com/plugin/15251-alpine-js-support) 5 | [![Downloads](https://img.shields.io/jetbrains/plugin/d/15251-alpine-js-support.svg)](https://plugins.jetbrains.com/plugin/15251-alpine-js-support) 6 | ![Release](https://github.com/inxilpro/IntellijAlpine/workflows/Release/badge.svg) 7 | 8 | ## Release Workflow 9 | 10 | 1. Update `gradle.properties` with the **new plugin version** 11 | 2. Update `CHANGELOG.md` to reflect the new changes in the **Unreleased** section 12 | 3. Push `main` branch and allow all GitHub Actions to run 13 | 4. Go to [releases page](https://github.com/inxilpro/IntellijAlpine/releases) and edit/publish draft release 14 | 15 | ## Plugin Description 16 | 17 | 18 | ![intellij-alpine](https://user-images.githubusercontent.com/21592/121929093-d7e0c400-cd0e-11eb-8ff3-d52db5831a55.gif) 19 | 20 | This plugin adds support for the following [Alpine.js](https://github.com/alpinejs/alpine) features: 21 | 22 | - Auto-complete alpine directives such as `x-data` 23 | - Set the language to JavaScript inside your directives so that you have full 24 | syntax highlighting and code complete 25 | 26 | 27 | 28 | ## Installation 29 | 30 | - Using IDE built-in plugin system: 31 | 32 | Preferences > Plugins > Marketplace > Search for "Alpine.js Support" > 33 | Install Plugin 34 | 35 | - Manually: 36 | 37 | Download the [latest release](https://github.com/inxilpro/IntellijAlpine/releases/latest) and install it manually using 38 | Preferences > Plugins > ⚙️ > Install plugin from disk... 39 | 40 | ## Future Improvements 41 | 42 | The Alpine.js Support plugin provides solid foundational features, but there are many opportunities to enhance the 43 | developer experience. Here are potential improvements organized by priority: 44 | 45 | ### High Priority Enhancements 46 | 47 | - **Go to Declaration & Find Usages**: Navigate between x-data property definitions and their usage across components. 48 | This would allow developers to quickly jump between related code sections. 49 | 50 | - **Refactoring Support**: Enable renaming of Alpine properties/methods with automatic updates across all references. 51 | This is essential for maintaining large Alpine.js applications. 52 | 53 | - **Code Inspections**: Add intelligent inspections to catch common mistakes: 54 | 55 | - Undefined property usage in Alpine expressions 56 | - Deprecated Alpine v2 syntax when using v3 57 | - Missing required modifiers (e.g., `.prevent` on form submissions) 58 | - Performance anti-patterns 59 | 60 | - **Test Coverage**: Implement comprehensive unit and integration tests to ensure plugin stability and make future development safer. 61 | 62 | ### Medium Priority Features 63 | 64 | - **Live Templates**: Provide code snippets for common Alpine patterns: 65 | 66 | - Component boilerplate with x-data 67 | - Common directive combinations (x-show with x-transition) 68 | - Alpine store definitions 69 | - Event handler patterns 70 | 71 | - **Quick Documentation**: Show inline documentation for Alpine directives and modifiers on hover or with F1/Ctrl+Q. 72 | 73 | - **Structure View**: Display Alpine component hierarchy in the IDE's structure panel, showing x-data scopes and their properties. 74 | 75 | - **Enhanced Type Support**: Improve JavaScript/TypeScript type inference within Alpine expressions for better auto-completion and error detection. 76 | 77 | ### Advanced Features 78 | 79 | - **Debugging Support**: Special debugging features for Alpine components, such as inspecting reactive data state. 80 | 81 | - **Code Generation**: Actions to quickly generate: 82 | - Alpine components from templates 83 | - Store definitions 84 | - Common patterns (modals, dropdowns, etc.) 85 | 86 | - **Performance Analysis**: Tools to analyze component complexity and suggest optimizations. 87 | 88 | - **Plugin Ecosystem Support**: Add support for popular Alpine plugins like Alpine Morph, Persist, Focus, and Intersect. 89 | 90 | ### Technical Improvements 91 | 92 | - **Better Error Handling**: Provide clearer error messages and recovery options when things go wrong. 93 | 94 | - **Performance Optimizations**: Profile and optimize completion providers and inspections for better IDE performance. 95 | 96 | - **Configuration Options**: Allow users to customize plugin behavior, such as: 97 | 98 | - Preferred Alpine version for syntax checking 99 | - Custom directive definitions for third-party plugins 100 | - Code style preferences 101 | 102 | - **Project-wide Analysis**: Analyze entire projects to find unused Alpine components or properties. 103 | 104 | --- 105 | Plugin based on the [IntelliJ Platform Plugin Template][template]. 106 | 107 | [template]: https://github.com/JetBrains/intellij-platform-plugin-template 108 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/core/AlpinePluginRegistry.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.core 2 | 3 | import com.github.inxilpro.intellijalpine.attributes.AttributeInfo 4 | import com.github.inxilpro.intellijalpine.completion.AutoCompleteSuggestions 5 | import com.github.inxilpro.intellijalpine.core.detection.PluginDetector 6 | import com.github.inxilpro.intellijalpine.settings.AlpineProjectSettingsState 7 | import com.intellij.openapi.application.ApplicationManager 8 | import com.intellij.openapi.components.Service 9 | import com.intellij.openapi.project.Project 10 | import com.intellij.openapi.vfs.VirtualFileManager 11 | import com.intellij.openapi.vfs.newvfs.BulkFileListener 12 | import com.intellij.openapi.vfs.newvfs.events.VFileEvent 13 | import com.intellij.util.messages.MessageBusConnection 14 | import org.apache.commons.lang3.tuple.MutablePair 15 | import java.util.concurrent.ConcurrentHashMap 16 | 17 | @Service(Service.Level.APP) 18 | class AlpinePluginRegistry { 19 | private val listeners = ConcurrentHashMap() 20 | private val detector = PluginDetector() 21 | 22 | companion object { 23 | val instance: AlpinePluginRegistry 24 | get() = ApplicationManager.getApplication().getService(AlpinePluginRegistry::class.java) 25 | } 26 | 27 | fun getRegisteredPlugins(): List { 28 | return AlpinePlugin.EP_NAME.extensionList 29 | } 30 | 31 | fun getEnabledPlugins(project: Project): List { 32 | val settings = AlpineProjectSettingsState.getInstance(project) 33 | return getRegisteredPlugins().filter { settings.isPluginEnabled(it.getPluginName()) } 34 | } 35 | 36 | fun isPluginEnabled(project: Project, pluginName: String): Boolean { 37 | return AlpineProjectSettingsState.getInstance(project).isPluginEnabled(pluginName) 38 | } 39 | 40 | fun isPluginEnabled(project: Project, plugin: AlpinePlugin): Boolean { 41 | return isPluginEnabled(project, plugin.getPluginName()) 42 | } 43 | 44 | fun enablePlugin(project: Project, pluginName: String) { 45 | AlpineProjectSettingsState.getInstance(project).setPluginEnabled(pluginName, true) 46 | } 47 | 48 | fun disablePlugin(project: Project, pluginName: String) { 49 | AlpineProjectSettingsState.getInstance(project).setPluginEnabled(pluginName, false) 50 | } 51 | 52 | fun getAllDirectives(project: Project): List { 53 | return getEnabledPlugins(project).flatMap { it.getDirectives() } 54 | } 55 | 56 | fun getAllPrefixes(project: Project): List { 57 | return getEnabledPlugins(project).flatMap { it.getPrefixes() } 58 | } 59 | 60 | fun getTypeText(info: AttributeInfo): String? { 61 | return getRegisteredPlugins().firstNotNullOfOrNull { it.getTypeText(info) } 62 | } 63 | 64 | fun injectAllJsContext(project: Project, context: MutablePair): MutablePair { 65 | return getEnabledPlugins(project).fold(context) { acc, plugin -> 66 | plugin.injectJsContext(acc) 67 | } 68 | } 69 | 70 | fun injectAllAutoCompleteSuggestions(project: Project, suggestions: AutoCompleteSuggestions) { 71 | getEnabledPlugins(project).forEach { it.injectAutoCompleteSuggestions(suggestions) } 72 | } 73 | 74 | fun checkAndAutoEnablePlugins(project: Project) { 75 | getRegisteredPlugins().forEach { plugin -> 76 | if (!isPluginEnabled(project, plugin.getPluginName()) && detector.detect(project, plugin)) { 77 | enablePlugin(project, plugin.getPluginName()) 78 | } 79 | } 80 | 81 | setupPackageJsonListener(project) 82 | } 83 | 84 | private fun setupPackageJsonListener(project: Project) { 85 | val projectPath = project.basePath ?: project.name 86 | 87 | // Don't set up listener if already exists 88 | if (listeners.containsKey(projectPath)) { 89 | return 90 | } 91 | 92 | val connection = project.messageBus.connect() 93 | listeners[projectPath] = connection 94 | 95 | connection.subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener { 96 | override fun after(events: List) { 97 | val hasPackageJsonChanges = events.any { event -> 98 | val file = event.file 99 | file != null && file.name == "package.json" 100 | } 101 | 102 | if (hasPackageJsonChanges) { 103 | ApplicationManager.getApplication().runReadAction { 104 | getRegisteredPlugins().forEach { plugin -> 105 | if (!isPluginEnabled(project, plugin.getPluginName()) && detector.detect(project, plugin)) { 106 | enablePlugin(project, plugin.getPluginName()) 107 | } 108 | } 109 | } 110 | } 111 | } 112 | }) 113 | } 114 | 115 | fun cleanup(project: Project) { 116 | val projectPath = project.basePath ?: project.name 117 | listeners.remove(projectPath)?.disconnect() 118 | } 119 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/completion/AutoCompleteSuggestions.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.completion 2 | 3 | import com.github.inxilpro.intellijalpine.attributes.AttributeInfo 4 | import com.github.inxilpro.intellijalpine.attributes.AttributeUtil 5 | import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry 6 | import com.intellij.psi.html.HtmlTag 7 | import com.intellij.psi.impl.source.html.dtd.HtmlElementDescriptorImpl 8 | import com.intellij.psi.impl.source.html.dtd.HtmlNSDescriptorImpl 9 | import com.intellij.psi.xml.XmlTag 10 | import com.intellij.xml.XmlAttributeDescriptor 11 | 12 | class AutoCompleteSuggestions(val htmlTag: HtmlTag, val partialAttribute: String) { 13 | 14 | val descriptors: MutableList = mutableListOf() 15 | 16 | private val tagName: String = htmlTag.name 17 | 18 | init { 19 | addDirectives() 20 | addPrefixes() 21 | addDerivedAttributes() 22 | addTransitions() 23 | addPlugins() 24 | } 25 | 26 | private fun addDirectives() { 27 | for (directive in AttributeUtil.getDirectivesForProject(htmlTag.project)) { 28 | if (tagName != "template" && AttributeUtil.isTemplateDirective(directive)) { 29 | continue 30 | } 31 | 32 | descriptors.add(AttributeInfo(directive)) 33 | 34 | if ("x-model" == directive) { 35 | addModifiers(directive, AttributeUtil.modelModifiers) 36 | } 37 | 38 | if ("x-intersect" == directive) { 39 | addModifiers(directive, AttributeUtil.intersectModifiers) 40 | } 41 | } 42 | } 43 | 44 | private fun addPrefixes() { 45 | for (prefix in AttributeUtil.getXmlPrefixesForProject(htmlTag.project)) { 46 | descriptors.add(AttributeInfo(prefix)) 47 | } 48 | } 49 | 50 | private fun addDerivedAttributes() { 51 | if (!AttributeUtil.isEvent(partialAttribute) && !AttributeUtil.isBound(partialAttribute)) { 52 | return 53 | } 54 | 55 | for (descriptor in getDefaultHtmlAttributes(htmlTag)) { 56 | if (descriptor.name.startsWith("on")) { 57 | addEvent(descriptor) 58 | } else { 59 | addBoundAttribute(descriptor) 60 | } 61 | } 62 | } 63 | 64 | private fun addTransitions() { 65 | val stages: Array = arrayOf( 66 | "enter", 67 | "enter-start", 68 | "enter-end", 69 | "leave", 70 | "leave-start", 71 | "leave-end", 72 | ) 73 | 74 | addModifiers("x-transition", AttributeUtil.transitionModifiers) 75 | 76 | for (stage in stages) { 77 | descriptors.add(AttributeInfo("x-transition:$stage")) 78 | addModifiers("x-transition:$stage", AttributeUtil.transitionModifiers) 79 | } 80 | } 81 | 82 | private fun addPlugins() { 83 | AlpinePluginRegistry.instance.injectAllAutoCompleteSuggestions(htmlTag.project, this) 84 | } 85 | 86 | private fun addEvent(descriptor: XmlAttributeDescriptor) { 87 | val event = descriptor.name.substring(2) 88 | for (prefix in AttributeUtil.eventPrefixes) { 89 | descriptors.add(AttributeInfo(prefix + event)) 90 | 91 | addModifiers("$prefix$event", AttributeUtil.eventModifiers) 92 | 93 | if (event.lowercase() == "keydown" || event.lowercase() == "keyup") { 94 | addModifiers("$prefix$event", AttributeUtil.keypressModifiers) 95 | } 96 | } 97 | } 98 | 99 | private fun addBoundAttribute(descriptor: XmlAttributeDescriptor) { 100 | for (prefix in AttributeUtil.bindPrefixes) { 101 | descriptors.add(AttributeInfo(prefix + descriptor.name)) 102 | } 103 | } 104 | 105 | fun addModifiers(modifiableDirective: String, modifiers: Array) { 106 | if (!partialAttribute.startsWith(modifiableDirective)) { 107 | return 108 | } 109 | 110 | var withExistingModifiers = partialAttribute 111 | 112 | if (partialAttribute.contains('.')) { 113 | withExistingModifiers = partialAttribute.substringBeforeLast('.') 114 | } 115 | 116 | for (modifier in modifiers) { 117 | if (!partialAttribute.contains(".$modifier")) { 118 | descriptors.add(AttributeInfo("$withExistingModifiers.$modifier")) 119 | } 120 | } 121 | 122 | val timeLimits = arrayOf( 123 | "75ms", 124 | "100ms", 125 | "150ms", 126 | "200ms", 127 | "300ms", 128 | "500ms", 129 | "700ms", 130 | "1000ms", 131 | ) 132 | for (timeUnitModifier in AttributeUtil.timeUnitModifiers) { 133 | if (withExistingModifiers.endsWith(".$timeUnitModifier")) { 134 | for (timeLimit in timeLimits) { 135 | descriptors.add(AttributeInfo("$withExistingModifiers.$timeLimit")) 136 | } 137 | } 138 | } 139 | 140 | val numbers = arrayOf("10", "20", "30", "40", "50", "60", "70", "80", "90") 141 | if (withExistingModifiers.endsWith(".scale")) { 142 | for (number in numbers) { 143 | descriptors.add(AttributeInfo("$withExistingModifiers.$number")) 144 | } 145 | } 146 | 147 | val origins = arrayOf("top", "bottom", "left", "right") 148 | if (withExistingModifiers.endsWith(".origin")) { 149 | for (origin in origins) { 150 | descriptors.add(AttributeInfo("$withExistingModifiers.$origin")) 151 | } 152 | } 153 | } 154 | 155 | private fun getDefaultHtmlAttributes(htmlTag: XmlTag): Array { 156 | val tagDescriptor = htmlTag.descriptor as? HtmlElementDescriptorImpl 157 | val descriptor = tagDescriptor ?: HtmlNSDescriptorImpl.guessTagForCommonAttributes(htmlTag) 158 | 159 | return (descriptor as? HtmlElementDescriptorImpl)?.getDefaultAttributeDescriptors(htmlTag) ?: emptyArray() 160 | } 161 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Alpine.js Support 4 | 5 | ## [Unreleased] 6 | 7 | ### Changed 8 | - Improved support for [alpine-wizard](https://github.com/glhd/alpine-wizard) 9 | - Improved overall performance of plugin 10 | 11 | ### Added 12 | - Added support for [alpine-ajax](https://alpine-ajax.js.org/) 13 | - Added basic support for [alpine-tooltip](https://github.com/ryangjchandler/alpine-tooltip) 14 | - Added configuration for plugins (enable/disable) when not auto-detected 15 | - Added support for newer IntelliJ platforms 16 | - Added better local PhpStorm testing 17 | - Added better handling of non-HTML file types 18 | - Added new plugin extension system 19 | 20 | ## [0.6.6] - 2025-06-19 21 | 22 | ### Fixed 23 | - Fixed [PsiInvalidElementAccessException bug](https://github.com/inxilpro/IntellijAlpine/issues/79) 24 | 25 | ## [0.6.5] - 2024-05-13 26 | This is just a version bump to add support for new IntelliJ platforms. 27 | 28 | ## [0.6.4] - 2024-01-16 29 | 30 | ### Fixed 31 | - Reverted 'go to' changes added in `0.6.1` — it introduced too many bugs when using templating languages like Blade 32 | 33 | ## [0.6.3] - 2024-01-02 34 | A bug was introduced that impacted Alpine when you did something like `x-data="@js([...])"`. This may not be a perfect fix, but it should help address it somewhat until I can find a better fix. 35 | 36 | ### Fixed 37 | - Improved 'go to' support in Blade 38 | 39 | ## [0.6.2] - 2024-01-02 40 | 41 | ### Fixed 42 | - Fixed intellij platform version constraints 43 | 44 | ## [0.6.1] - 2024-01-02 45 | 46 | ### Added 47 | - Added typing support for `$el` and `$root` 48 | - Improved intellisense for `x-data` derived properties 49 | - Added initial support for Go To Declaration for `x-data` derived properties 50 | 51 | ## [0.6.0] - 2023-08-15 52 | 53 | ### Added 54 | - Added support for IntelliJ platform version 2023.2 55 | - Added support for [`glhd/alpine-wizard`](https://github.com/glhd/alpine-wizard) 56 | 57 | ## [0.5.0] - 2023-05-31 58 | 59 | ### Added 60 | - Added better type support for `$refs` 61 | - Added support for `x-id` and `$id()` 62 | - Added support for `x-mask` 63 | - Added support for `x-modelable` 64 | - Added support for `x-teleport` 65 | - Added support for `x-trap` 66 | - Added support for `x-collapse` 67 | - Added new help text for `x-` attributes 68 | 69 | ## [0.4.2] 70 | 71 | ### Added 72 | - Added support for Blade directives like `@entangle()` inside Alpine directives 73 | - Added option to disable gutter icons 74 | 75 | ### Fixed 76 | - Addressed some issues where certain characters couldn't appear at the beginning or end of certain directives 77 | - Fixed issue where Markdown plugin was interfering with Alpine gutter icon 78 | 79 | ## [0.4.1] 80 | 81 | ### Added 82 | - Added support for `x-intersect` 83 | 84 | ### Changed 85 | - Improved `$persist()` behavior 86 | - Improved autocomplete in `x-data` and `x-init` 87 | 88 | ## [0.4.0] 89 | 90 | ### Added 91 | - Added support for PHP and Blade fragments inside of Alpine directives 92 | - Added Alpine gutter icon for easier identification of lines that have Alpine directives 93 | - Added support for Alpine v3 directives and magics 94 | - Added better support for modifiers like `@click.prevent` 95 | 96 | ### Changed 97 | - Improved auto-complete logic 98 | 99 | ## [0.3.0] 100 | 101 | ### Added 102 | - Added auto-complete support for simple x-data expressions 103 | - Added better support for `x-for` and `x-spread` 104 | - Added better language injection support for all Alpine directives 105 | 106 | ### Fixed 107 | - Fixed an issue where the plugin would cause the IDE freeze when editing XML files 108 | 109 | ## [0.2.2] 110 | 111 | ### Added 112 | - Added improved support for transition attributes 113 | 114 | ## [0.2.1] 115 | 116 | ### Fixed 117 | - Prevented autocompletion on Laravel blade components 118 | - Prevented language injection and autocompletion when outside of HTML scope 119 | 120 | ## [0.2.0] 121 | 122 | ### Added 123 | - Improved type support 124 | - Better auto-complete for bound attributes 125 | - Added attribute descriptions 126 | - Handling of `x-cloak` which has no value 127 | 128 | ### Changed 129 | - Refactored a lot of underlying code 130 | 131 | ## [0.1.1] 132 | 133 | ### Added 134 | - Support for object style `:class=` binding 135 | 136 | ## [0.1.0] 137 | 138 | ### Added 139 | - Better auto-complete for bound attributes and events (using the IDE's suggestions for attributes 140 | rather than an internal list of available attributes) 141 | 142 | ### Changed 143 | - Internals refactor 144 | 145 | ## [0.0.3] 146 | 147 | ### Added 148 | - Better JS language injection inside `x-` directives 149 | - Auto-complete for magic properties like `$el` and `$dispatch` 150 | 151 | ### Fixed 152 | - Removed Alpine icon which seemed to cause issues for some people 153 | 154 | [Unreleased]: https://github.com/inxilpro/IntellijAlpine/compare/v0.6.6...HEAD 155 | [0.6.6]: https://github.com/inxilpro/IntellijAlpine/compare/v0.6.5...v0.6.6 156 | [0.6.5]: https://github.com/inxilpro/IntellijAlpine/compare/v0.6.4...v0.6.5 157 | [0.6.4]: https://github.com/inxilpro/IntellijAlpine/compare/v0.6.3...v0.6.4 158 | [0.6.3]: https://github.com/inxilpro/IntellijAlpine/compare/v0.6.2...v0.6.3 159 | [0.6.2]: https://github.com/inxilpro/IntellijAlpine/compare/v0.6.0...v0.6.2 160 | [0.6.1]: https://github.com/inxilpro/IntellijAlpine/compare/v0.6.0...v0.6.1 161 | [0.6.0]: https://github.com/inxilpro/IntellijAlpine/compare/v0.5.0...v0.6.0 162 | [0.5.0]: https://github.com/inxilpro/IntellijAlpine/compare/v0.4.2...v0.5.0 163 | [0.4.2]: https://github.com/inxilpro/IntellijAlpine/compare/v0.4.1...v0.4.2 164 | [0.4.1]: https://github.com/inxilpro/IntellijAlpine/compare/v0.4.0...v0.4.1 165 | [0.4.0]: https://github.com/inxilpro/IntellijAlpine/compare/v0.3.0...v0.4.0 166 | [0.3.0]: https://github.com/inxilpro/IntellijAlpine/compare/v0.2.2...v0.3.0 167 | [0.2.2]: https://github.com/inxilpro/IntellijAlpine/compare/v0.2.1...v0.2.2 168 | [0.2.1]: https://github.com/inxilpro/IntellijAlpine/compare/v0.2.0...v0.2.1 169 | [0.2.0]: https://github.com/inxilpro/IntellijAlpine/compare/v0.1.1...v0.2.0 170 | [0.1.1]: https://github.com/inxilpro/IntellijAlpine/compare/v0.1.0...v0.1.1 171 | [0.1.0]: https://github.com/inxilpro/IntellijAlpine/compare/v0.0.3...v0.1.0 172 | [0.0.3]: https://github.com/inxilpro/IntellijAlpine/commits/v0.0.3 173 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow is created for testing and preparing the plugin release in the following steps: 2 | # - Validate Gradle Wrapper. 3 | # - Run 'test' and 'verifyPlugin' tasks. 4 | # - Run Qodana inspections. 5 | # - Run the 'buildPlugin' task and prepare artifact for further tests. 6 | # - Run the 'runPluginVerifier' task. 7 | # - Create a draft release. 8 | # 9 | # The workflow is triggered on push and pull_request events. 10 | # 11 | # GitHub Actions reference: https://help.github.com/en/actions 12 | # 13 | ## JBIJPPTPL 14 | 15 | name: Build 16 | on: 17 | # Trigger the workflow on pushes to only the 'main' branch (this avoids duplicate checks being run e.g., for dependabot pull requests) 18 | push: 19 | branches: [ main ] 20 | # Trigger the workflow on any pull request 21 | pull_request: 22 | 23 | concurrency: 24 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 25 | cancel-in-progress: true 26 | 27 | jobs: 28 | 29 | # Prepare environment and build the plugin 30 | build: 31 | name: Build 32 | runs-on: ubuntu-latest 33 | outputs: 34 | version: ${{ steps.properties.outputs.version }} 35 | changelog: ${{ steps.properties.outputs.changelog }} 36 | pluginVerifierHomeDir: ${{ steps.properties.outputs.pluginVerifierHomeDir }} 37 | steps: 38 | 39 | # Check out the current repository 40 | - name: Fetch Sources 41 | uses: actions/checkout@v4 42 | 43 | # Set up Java environment for the next steps 44 | - name: Setup Java 45 | uses: actions/setup-java@v4 46 | with: 47 | distribution: zulu 48 | java-version: 21 49 | 50 | # Setup Gradle 51 | - name: Setup Gradle 52 | uses: gradle/actions/setup-gradle@v4 53 | 54 | # Set environment variables 55 | - name: Export Properties 56 | id: properties 57 | shell: bash 58 | run: | 59 | PROPERTIES="$(./gradlew properties --console=plain -q)" 60 | VERSION="$(echo "$PROPERTIES" | grep "^version:" | cut -f2- -d ' ')" 61 | CHANGELOG="$(./gradlew getChangelog --unreleased --no-header --console=plain -q)" 62 | 63 | echo "version=$VERSION" >> $GITHUB_OUTPUT 64 | echo "pluginVerifierHomeDir=~/.pluginVerifier" >> $GITHUB_OUTPUT 65 | 66 | echo "changelog<> $GITHUB_OUTPUT 67 | echo "$CHANGELOG" >> $GITHUB_OUTPUT 68 | echo "EOF" >> $GITHUB_OUTPUT 69 | 70 | # Build plugin 71 | - name: Build plugin 72 | run: ./gradlew buildPlugin 73 | 74 | # Prepare plugin archive content for creating artifact 75 | - name: Prepare Plugin Artifact 76 | id: artifact 77 | shell: bash 78 | run: | 79 | cd ${{ github.workspace }}/build/distributions 80 | FILENAME=`ls *.zip` 81 | unzip "$FILENAME" -d content 82 | 83 | echo "filename=${FILENAME:0:-4}" >> $GITHUB_OUTPUT 84 | 85 | # Store already-built plugin as an artifact for downloading 86 | - name: Upload artifact 87 | uses: actions/upload-artifact@v4 88 | with: 89 | name: ${{ steps.artifact.outputs.filename }} 90 | path: ./build/distributions/content/*/* 91 | 92 | # Run tests and upload a code coverage report 93 | test: 94 | name: Test 95 | needs: [ build ] 96 | runs-on: ubuntu-latest 97 | steps: 98 | 99 | # Check out the current repository 100 | - name: Fetch Sources 101 | uses: actions/checkout@v4 102 | 103 | # Set up Java environment for the next steps 104 | - name: Setup Java 105 | uses: actions/setup-java@v4 106 | with: 107 | distribution: zulu 108 | java-version: 21 109 | 110 | # Setup Gradle 111 | - name: Setup Gradle 112 | uses: gradle/actions/setup-gradle@v4 113 | 114 | # Run tests 115 | - name: Run Tests 116 | run: ./gradlew check 117 | 118 | # Collect Tests Result of failed tests 119 | - name: Collect Tests Result 120 | if: ${{ failure() }} 121 | uses: actions/upload-artifact@v4 122 | with: 123 | name: tests-result 124 | path: ${{ github.workspace }}/build/reports/tests 125 | 126 | # Upload the Kover report to CodeCov 127 | - name: Upload Code Coverage Report 128 | uses: codecov/codecov-action@v5 129 | with: 130 | files: ${{ github.workspace }}/build/reports/kover/report.xml 131 | 132 | # Run Qodana inspections and provide report 133 | inspectCode: 134 | name: Inspect code 135 | needs: [ build ] 136 | runs-on: ubuntu-latest 137 | permissions: 138 | contents: write 139 | checks: write 140 | pull-requests: write 141 | steps: 142 | 143 | # Free GitHub Actions Environment Disk Space 144 | - name: Maximize Build Space 145 | uses: jlumbroso/free-disk-space@main 146 | with: 147 | tool-cache: false 148 | large-packages: false 149 | 150 | # Check out the current repository 151 | - name: Fetch Sources 152 | uses: actions/checkout@v4 153 | with: 154 | ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit 155 | fetch-depth: 0 # a full history is required for pull request analysis 156 | 157 | # Set up Java environment for the next steps 158 | - name: Setup Java 159 | uses: actions/setup-java@v4 160 | with: 161 | distribution: zulu 162 | java-version: 21 163 | 164 | # Run Qodana inspections 165 | - name: Qodana - Code Inspection 166 | uses: JetBrains/qodana-action@v2025.1 167 | with: 168 | cache-default-branch-only: true 169 | 170 | # Run plugin structure verification along with IntelliJ Plugin Verifier 171 | verify: 172 | name: Verify plugin 173 | needs: [ build ] 174 | runs-on: ubuntu-latest 175 | steps: 176 | 177 | # Free GitHub Actions Environment Disk Space 178 | - name: Maximize Build Space 179 | uses: jlumbroso/free-disk-space@main 180 | with: 181 | tool-cache: false 182 | large-packages: false 183 | 184 | # Check out the current repository 185 | - name: Fetch Sources 186 | uses: actions/checkout@v4 187 | 188 | # Set up Java environment for the next steps 189 | - name: Setup Java 190 | uses: actions/setup-java@v4 191 | with: 192 | distribution: zulu 193 | java-version: 21 194 | 195 | # Setup Gradle 196 | - name: Setup Gradle 197 | uses: gradle/actions/setup-gradle@v4 198 | 199 | # Cache Plugin Verifier IDEs 200 | - name: Setup Plugin Verifier IDEs Cache 201 | uses: actions/cache@v4 202 | with: 203 | path: ${{ needs.build.outputs.pluginVerifierHomeDir }}/ides 204 | key: plugin-verifier-${{ hashFiles('build/listProductsReleases.txt') }} 205 | 206 | # Run Verify Plugin task and IntelliJ Plugin Verifier tool 207 | - name: Run Plugin Verification tasks 208 | run: ./gradlew verifyPlugin -Dplugin.verifier.home.dir=${{ needs.build.outputs.pluginVerifierHomeDir }} 209 | 210 | # Collect Plugin Verifier Result 211 | - name: Collect Plugin Verifier Result 212 | if: ${{ always() }} 213 | uses: actions/upload-artifact@v4 214 | with: 215 | name: pluginVerifier-result 216 | path: ${{ github.workspace }}/build/reports/pluginVerifier 217 | 218 | # Prepare a draft release for GitHub Releases page for the manual verification 219 | # If accepted and published, release workflow would be triggered 220 | releaseDraft: 221 | name: Release draft 222 | if: github.event_name != 'pull_request' 223 | needs: [ build, test, inspectCode, verify ] 224 | runs-on: ubuntu-latest 225 | permissions: 226 | contents: write 227 | steps: 228 | 229 | # Check out the current repository 230 | - name: Fetch Sources 231 | uses: actions/checkout@v4 232 | 233 | # Remove old release drafts by using the curl request for the available releases with a draft flag 234 | - name: Remove Old Release Drafts 235 | env: 236 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 237 | run: | 238 | gh api repos/{owner}/{repo}/releases \ 239 | --jq '.[] | select(.draft == true) | .id' \ 240 | | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} 241 | 242 | # Create a new release draft which is not publicly visible and requires manual acceptance 243 | - name: Create Release Draft 244 | env: 245 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 246 | run: | 247 | gh release create "v${{ needs.build.outputs.version }}" \ 248 | --draft \ 249 | --title "v${{ needs.build.outputs.version }}" \ 250 | --notes "$(cat << 'EOM' 251 | ${{ needs.build.outputs.changelog }} 252 | EOM 253 | )" 254 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/injection/AlpineJavaScriptAttributeValueInjector.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.injection 2 | 3 | import com.github.inxilpro.intellijalpine.attributes.AttributeUtil 4 | import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry 5 | import com.github.inxilpro.intellijalpine.support.LanguageUtil 6 | import com.intellij.lang.Language 7 | import com.intellij.lang.injection.MultiHostInjector 8 | import com.intellij.lang.injection.MultiHostRegistrar 9 | import com.intellij.openapi.util.TextRange 10 | import com.intellij.psi.ElementManipulators 11 | import com.intellij.psi.PsiElement 12 | import com.intellij.psi.PsiLanguageInjectionHost 13 | import com.intellij.psi.html.HtmlTag 14 | import com.intellij.psi.util.PsiTreeUtil 15 | import com.intellij.psi.xml.XmlAttribute 16 | import com.intellij.psi.xml.XmlAttributeValue 17 | import com.intellij.psi.xml.XmlTag 18 | import org.apache.commons.lang3.tuple.MutablePair 19 | import org.apache.html.dom.HTMLDocumentImpl 20 | 21 | class AlpineJavaScriptAttributeValueInjector : MultiHostInjector { 22 | private val globalState = 23 | """ 24 | /** @type {Object.} */ 25 | let ${'$'}refs; 26 | 27 | /** @type {Object.} */ 28 | let ${'$'}store; 29 | 30 | """.trimIndent() 31 | 32 | private val globalMagics = 33 | """ 34 | /** 35 | * @param {*} value 36 | * @return {ValueToPersist} 37 | * @template ValueToPersist 38 | */ 39 | function ${'$'}persist(value) {} 40 | 41 | /** 42 | * @param {*} value 43 | * @return {ValueForQueryString} 44 | * @template ValueForQueryString 45 | */ 46 | function ${'$'}queryString(value) {} 47 | 48 | """.trimIndent() 49 | 50 | private val coreMagics = 51 | """ 52 | /** @type {elType} */ 53 | let ${'$'}el; 54 | 55 | /** @type {rootType} */ 56 | let ${'$'}root; 57 | 58 | /** 59 | * @param {string} event 60 | * @param {Object} detail 61 | * @return boolean 62 | */ 63 | function ${'$'}dispatch(event, detail = {}) {} 64 | 65 | /** 66 | * @param {Function} callback 67 | * @return void 68 | */ 69 | function ${'$'}nextTick(callback) {} 70 | 71 | /** 72 | * @param {string} property 73 | * @param {Function} callback 74 | * @return void 75 | */ 76 | function ${'$'}watch(property, callback) {} 77 | 78 | /** 79 | * @param {string} scope 80 | * @return string 81 | */ 82 | function ${'$'}id(scope) {} 83 | 84 | """.trimIndent() 85 | 86 | private val eventMagics = "/** @type {eventType} */\nlet ${'$'}event;\n\n" 87 | 88 | override fun getLanguagesToInject(registrar: MultiHostRegistrar, host: PsiElement) { 89 | if (host !is XmlAttributeValue) { 90 | return 91 | } 92 | if (!AttributeUtil.isValidInjectionTarget(host)) { 93 | return 94 | } 95 | 96 | val attribute = host.parent as? XmlAttribute ?: return 97 | val attributeName = attribute.name 98 | 99 | val content = host.text 100 | val ranges = getJavaScriptRanges(host, content) 101 | 102 | var (prefix, suffix) = getPrefixAndSuffix(attributeName, host) 103 | 104 | val jsLanguage = Language.findLanguageByID("JavaScript") 105 | ?: throw IllegalStateException("JavaScript language not found") 106 | registrar.startInjecting(jsLanguage) 107 | 108 | ranges.forEachIndexed { index, range -> 109 | if (index == ranges.lastIndex) { 110 | registrar.addPlace(prefix, suffix, host as PsiLanguageInjectionHost, range) 111 | } else { 112 | registrar.addPlace(prefix, "", host as PsiLanguageInjectionHost, range) 113 | } 114 | 115 | if (ranges.lastIndex != index) { 116 | prefix += range.substring(content) 117 | prefix += "__PHP_CALL()" 118 | } 119 | } 120 | 121 | registrar.doneInjecting() 122 | } 123 | 124 | override fun elementsToInjectIn(): List> { 125 | return listOf(XmlAttributeValue::class.java) 126 | } 127 | 128 | private fun getJavaScriptRanges(host: XmlAttributeValue, content: String): List { 129 | val valueRange = ElementManipulators.getValueTextRange(host) 130 | 131 | if (!LanguageUtil.hasPhpLanguage(host.containingFile)) { 132 | return listOf(valueRange) 133 | } 134 | 135 | val phpMatcher = Regex("(?|@[a-zA-Z]+\\(.*\\)(?:\\.defer)?") 136 | val ranges = mutableListOf() 137 | 138 | var offset = valueRange.startOffset 139 | phpMatcher.findAll(content).forEach { 140 | ranges.add(TextRange(offset, it.range.first)) 141 | offset = it.range.last + 1 142 | } 143 | 144 | ranges.add(TextRange(offset, valueRange.endOffset)) 145 | 146 | return ranges.toList() 147 | } 148 | 149 | private fun getPrefixAndSuffix(directive: String, host: XmlAttributeValue): Pair { 150 | val globalContext = MutablePair(globalMagics, "") 151 | val context = AlpinePluginRegistry.instance.injectAllJsContext(host.project, globalContext) 152 | 153 | if ("x-data" != directive) { 154 | context.left = addTypingToCoreMagics(host) + context.left 155 | } 156 | 157 | if ("x-spread" == directive) { 158 | context.right += "()" 159 | } 160 | 161 | if (AttributeUtil.isEvent(directive)) { 162 | context.left += addTypingToEventMagics(directive, host) 163 | } else if ("x-for" == directive) { 164 | context.left += "for (let " 165 | context.right += ") {}" 166 | } else if ("x-ref" == directive) { 167 | context.left += "\$refs." 168 | context.right += "= \$el" 169 | } else if ("x-teleport" == directive) { 170 | context.left += "{ /** @var {HTMLElement} teleport */let teleport = " 171 | context.right += " }" 172 | } else if ("x-init" == directive) { 173 | // We want x-init to skip the directive wrapping 174 | } else { 175 | context.left += "__ALPINE_DIRECTIVE(\n" 176 | context.right += "\n)" 177 | } 178 | 179 | addWithData(host, directive, context) 180 | 181 | return context.toPair() 182 | } 183 | 184 | private fun addWithData(host: XmlAttributeValue, directive: String, context: MutablePair) { 185 | val dataParent: HtmlTag? 186 | 187 | if ("x-data" == directive) { 188 | val parentTag = PsiTreeUtil.findFirstParent(host) { it is HtmlTag } ?: return 189 | dataParent = PsiTreeUtil.findFirstParent(parentTag) { 190 | it != parentTag && it is HtmlTag && it.getAttribute("x-data") != null 191 | } as HtmlTag? 192 | } else { 193 | dataParent = PsiTreeUtil.findFirstParent(host) { 194 | it is HtmlTag && it.getAttribute("x-data") != null 195 | } as HtmlTag? 196 | } 197 | 198 | if (dataParent is HtmlTag) { 199 | val data = dataParent.getAttribute("x-data")?.value 200 | if (null != data) { 201 | val (prefix, suffix) = context 202 | context.left = "$globalState\nlet ${'$'}data = $data;\nwith (${'$'}data) {\n\n$prefix" 203 | context.right = "$suffix\n\n}" 204 | } 205 | } 206 | } 207 | 208 | private fun addTypingToCoreMagics(host: XmlAttributeValue): String { 209 | var typedCoreMagics = coreMagics 210 | val attribute = host.parent as XmlAttribute 211 | val tag = attribute.parent 212 | 213 | fun jsElementNameFromXmlTag(tag: XmlTag): String { 214 | return try { 215 | HTMLDocumentImpl().createElement(tag.localName).javaClass.simpleName.removeSuffix("Impl") 216 | } catch (e: Exception) { 217 | "HTMLElement" 218 | } 219 | } 220 | 221 | // Determine type for $el 222 | run { 223 | val elType = jsElementNameFromXmlTag(tag) 224 | typedCoreMagics = typedCoreMagics.replace("{elType}", elType) 225 | } 226 | 227 | // Determine type for $root 228 | run { 229 | val elType = if (tag.getAttribute("x-data") != null) { 230 | jsElementNameFromXmlTag(tag) 231 | } else { 232 | PsiTreeUtil.findFirstParent(tag.parentTag) 233 | { it is HtmlTag && it.getAttribute("x-data") != null } 234 | ?.let { jsElementNameFromXmlTag(it as XmlTag) } 235 | ?: "HTMLElement" 236 | } 237 | typedCoreMagics = typedCoreMagics.replace("{rootType}", elType) 238 | } 239 | 240 | return typedCoreMagics 241 | } 242 | 243 | private fun addTypingToEventMagics(directive: String, host: XmlAttributeValue): String { 244 | val eventName = AttributeUtil.getEventNameFromDirective(directive) 245 | return eventMagics.replace("eventType", eventName) 246 | } 247 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | org.gradle.wrapper.GradleWrapperMain \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/inxilpro/intellijalpine/attributes/AttributeUtil.kt: -------------------------------------------------------------------------------- 1 | package com.github.inxilpro.intellijalpine.attributes 2 | 3 | import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry 4 | import com.github.inxilpro.intellijalpine.support.LanguageUtil 5 | import com.intellij.openapi.project.Project 6 | import com.intellij.psi.html.HtmlTag 7 | import com.intellij.psi.impl.source.html.dtd.HtmlAttributeDescriptorImpl 8 | import com.intellij.psi.xml.XmlAttribute 9 | import com.intellij.psi.xml.XmlAttributeValue 10 | 11 | object AttributeUtil { 12 | private val corePrefixes = listOf( 13 | "x-on", 14 | "x-bind", 15 | "x-transition", 16 | ) 17 | 18 | val prefixes: List by lazy { 19 | AlpinePluginRegistry.instance.getRegisteredPlugins() 20 | .flatMap { it.getPrefixes() } 21 | .union(corePrefixes) 22 | .toList() 23 | } 24 | 25 | private val coreDirectives = listOf( 26 | "x-data", 27 | "x-init", 28 | "x-show", 29 | "x-bind", 30 | "x-text", 31 | "x-html", 32 | "x-model", 33 | "x-modelable", 34 | "x-for", 35 | "x-transition", 36 | "x-effect", 37 | "x-ignore", 38 | "x-ref", 39 | "x-cloak", 40 | "x-teleport", 41 | "x-if", 42 | "x-id", 43 | "x-mask", 44 | "x-intersect", 45 | "x-trap", 46 | "x-collapse", 47 | "x-spread", // deprecated 48 | ) 49 | 50 | val directives: List by lazy { 51 | AlpinePluginRegistry.instance.getRegisteredPlugins() 52 | .flatMap { it.getDirectives() } 53 | .union(coreDirectives) 54 | .toList() 55 | } 56 | 57 | val templateDirectives = arrayOf( 58 | "x-if", 59 | "x-for", 60 | "x-teleport", 61 | ) 62 | 63 | val eventPrefixes = arrayOf( 64 | "@", 65 | "x-on:" 66 | ) 67 | 68 | val eventModifiers = arrayOf( 69 | "prevent", 70 | "stop", 71 | "outside", 72 | "window", 73 | "document", 74 | "once", 75 | "debounce", 76 | "throttle", 77 | "self", 78 | "camel", 79 | "passive" 80 | ) 81 | 82 | val bindPrefixes = arrayOf( 83 | ":", 84 | "x-bind:" 85 | ) 86 | 87 | val modelModifiers = arrayOf( 88 | "lazy", 89 | "number", 90 | "debounce", 91 | "throttle" 92 | ) 93 | 94 | val timeUnitModifiers = arrayOf( 95 | "debounce", 96 | "throttle", 97 | "duration", 98 | "delay", 99 | ) 100 | 101 | val transitionModifiers = arrayOf( 102 | "duration", 103 | "delay", 104 | "opacity", 105 | "scale", 106 | "origin", 107 | ) 108 | 109 | val keypressModifiers = arrayOf( 110 | "shift", 111 | "enter", 112 | "space", 113 | "ctrl", 114 | "cmd", 115 | "meta", 116 | "alt", 117 | "up", 118 | "down", 119 | "left", 120 | "right", 121 | "esc", 122 | "tab", 123 | "caps-lock", 124 | ) 125 | 126 | val intersectModifiers = arrayOf( 127 | "once" 128 | ) 129 | 130 | // Taken from https://developer.mozilla.org/en-US/docs 131 | val nameToInterfaceEventMap: Map = mapOf( 132 | Pair("afterscriptexecute", "Event"), 133 | Pair("animationcancel", "AnimationEvent"), 134 | Pair("animationend", "AnimationEvent"), 135 | Pair("animationiteration", "AnimationEvent"), 136 | Pair("animationstart", "AnimationEvent"), 137 | Pair("auxclick", "PointerEvent"), 138 | Pair("beforematch", "Event"), 139 | Pair("beforescriptexecute", "Event"), 140 | Pair("beforexrselect", "XRSessionEvent"), 141 | Pair("blur", "FocusEvent"), 142 | Pair("click", "PointerEvent"), 143 | Pair("compositionend", "CompositionEvent"), 144 | Pair("compositionstart", "CompositionEvent"), 145 | Pair("compositionupdate", "CompositionEvent"), 146 | Pair("contentvisibilityautostatechange", "ContentVisibilityAutoStateChangeEvent"), 147 | Pair("contextmenu", "PointerEvent"), 148 | Pair("copy", "ClipboardEvent"), 149 | Pair("cut", "ClipboardEvent"), 150 | Pair("dblclick", "MouseEvent"), 151 | Pair("DOMActivate", "MouseEvent"), 152 | Pair("DOMMouseScroll", "WheelEvent"), 153 | Pair("focus", "FocusEvent"), 154 | Pair("focusin", "FocusEvent"), 155 | Pair("focusout", "FocusEvent"), 156 | Pair("fullscreenchange", "Event"), 157 | Pair("fullscreenerror", "Event"), 158 | Pair("gesturechange", "GestureEvent"), 159 | Pair("gestureend", "GestureEvent"), 160 | Pair("gesturestart", "GestureEvent"), 161 | Pair("gotpointercapture", "PointerEvent"), 162 | Pair("keydown", "KeyboardEvent"), 163 | Pair("keypress", "KeyboardEvent"), 164 | Pair("keyup", "KeyboardEvent"), 165 | Pair("lostpointercapture", "PointerEvent"), 166 | Pair("mousedown", "MouseEvent"), 167 | Pair("mouseenter", "MouseEvent"), 168 | Pair("mouseleave", "MouseEvent"), 169 | Pair("mousemove", "MouseEvent"), 170 | Pair("mouseout", "MouseEvent"), 171 | Pair("mouseover", "MouseEvent"), 172 | Pair("mouseup", "MouseEvent"), 173 | Pair("mousewheel", "WheelEvent"), 174 | Pair("MozMousePixelScroll", "WheelEvent"), 175 | Pair("paste", "ClipboardEvent"), 176 | Pair("pointercancel", "PointerEvent"), 177 | Pair("pointerdown", "PointerEvent"), 178 | Pair("pointerenter", "PointerEvent"), 179 | Pair("pointerleave", "PointerEvent"), 180 | Pair("pointermove", "PointerEvent"), 181 | Pair("pointerout", "PointerEvent"), 182 | Pair("pointerover", "PointerEvent"), 183 | Pair("pointerrawupdate", "PointerEvent"), 184 | Pair("pointerup", "PointerEvent"), 185 | Pair("scroll", "Event"), 186 | Pair("scrollend", "Event"), 187 | Pair("securitypolicyviolation", "SecurityPolicyViolationEvent"), 188 | Pair("touchcancel", "TouchEvent"), 189 | Pair("touchend", "TouchEvent"), 190 | Pair("touchmove", "TouchEvent"), 191 | Pair("touchstart", "TouchEvent"), 192 | Pair("transitioncancel", "TransitionEvent"), 193 | Pair("transitionend", "TransitionEvent"), 194 | Pair("transitionrun", "TransitionEvent"), 195 | Pair("transitionstart", "TransitionEvent"), 196 | Pair("webkitmouseforcechanged", "MouseEvent"), 197 | Pair("webkitmouseforcedown", "MouseEvent"), 198 | Pair("webkitmouseforceup", "MouseEvent"), 199 | Pair("webkitmouseforcewillbegin", "MouseEvent"), 200 | Pair("wheel", "WheelEvent"), 201 | ) 202 | 203 | fun getDirectivesForProject(project: Project): Array { 204 | val pluginDirectives = AlpinePluginRegistry.instance.getAllDirectives(project) 205 | return (directives.toList() + pluginDirectives).toTypedArray() 206 | } 207 | 208 | fun getXmlPrefixesForProject(project: Project): Array { 209 | val pluginPrefixes = AlpinePluginRegistry.instance.getAllPrefixes(project) 210 | return (prefixes.toList() + pluginPrefixes).toTypedArray() 211 | } 212 | 213 | fun isXmlPrefix(prefix: String): Boolean { 214 | return prefixes.contains(prefix) 215 | } 216 | 217 | fun isTemplateDirective(directive: String): Boolean { 218 | return templateDirectives.contains(directive) 219 | } 220 | 221 | fun isEvent(attribute: String): Boolean { 222 | for (prefix in eventPrefixes) { 223 | if (attribute.startsWith(prefix)) { 224 | return true 225 | } 226 | } 227 | 228 | return false 229 | } 230 | 231 | fun isBound(attribute: String): Boolean { 232 | for (prefix in bindPrefixes) { 233 | if (attribute.startsWith(prefix)) { 234 | return true 235 | } 236 | } 237 | 238 | return false 239 | } 240 | 241 | fun isValidInjectionTarget(host: XmlAttributeValue): Boolean { 242 | if (!LanguageUtil.supportsAlpineJs(host.containingFile)) { 243 | return false 244 | } 245 | 246 | // Make sure that we have an XML attribute as a parent 247 | val attribute = host.parent as? XmlAttribute ?: return false 248 | 249 | // Make sure we have an HTML tag (and not a Blade acc.removePrefix(s) }.split(".") 276 | .first()] 277 | ?: "Event" 278 | } 279 | 280 | private fun isValidAttribute(attribute: XmlAttribute): Boolean { 281 | return attribute.descriptor is HtmlAttributeDescriptorImpl || attribute.descriptor is AlpineAttributeDescriptor 282 | } 283 | 284 | private fun isValidHtmlTag(tag: HtmlTag): Boolean { 285 | return !tag.name.startsWith("x-") 286 | } 287 | 288 | private fun isAlpineAttributeName(name: String): Boolean { 289 | return name.startsWith("x-") || name.startsWith("@") || name.startsWith(':') 290 | } 291 | 292 | private fun shouldInjectJavaScript(name: String, project: Project): Boolean { 293 | // Never inject for these core attributes 294 | if (name.startsWith("x-transition:") || name == "x-mask" || name == "x-modelable") { 295 | return false 296 | } 297 | 298 | 299 | val enabledPlugins = AlpinePluginRegistry.instance.getEnabledPlugins(project) 300 | for (plugin in enabledPlugins) { 301 | val pluginDirectives = plugin.getDirectives() 302 | val pluginPrefixes = plugin.getPrefixes() 303 | 304 | // If this attribute belongs to this plugin, let the plugin decide 305 | if (pluginDirectives.contains(name) || pluginPrefixes.any { name.startsWith("$it:") }) { 306 | return plugin.directiveSupportJavaScript(name) 307 | } 308 | } 309 | 310 | // For core attributes and unknown attributes, default to true (inject JS) 311 | return true 312 | } 313 | } --------------------------------------------------------------------------------