diff --git a/.changeset/floppy-singers-tan.md b/.changeset/floppy-singers-tan.md new file mode 100644 index 00000000..2eaa5948 --- /dev/null +++ b/.changeset/floppy-singers-tan.md @@ -0,0 +1,5 @@ +--- +'@embr-jvm/core-common': patch +--- + +Introduce `setPrivateProperty` extension function. diff --git a/.changeset/shy-bikes-sing.md b/.changeset/shy-bikes-sing.md new file mode 100644 index 00000000..3ac80d02 --- /dev/null +++ b/.changeset/shy-bikes-sing.md @@ -0,0 +1,6 @@ +--- +'@embr-modules/periscope-web': minor +'@embr-modules/periscope': minor +--- + +(React Component) Add React component. This component uses user-supplied to render a React component, with basic Babel transpilation support for JSX. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3b96652a..85b48c1d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ jetty = "10.0.21" kotlin = "1.9.20" kotest = "6.0.0" node-gradle = "7.1.0" +rhino = "1.7.15" snmp4j = "3.9.7" spotless = "8.2.1" @@ -33,6 +34,7 @@ ignition-perspective-gateway = { group = "com.inductiveautomation.ignitionsdk", jetty-server = { group = "org.eclipse.jetty", name = "jetty-server", version.ref = "jetty" } jetty-servlet = { group = "org.eclipse.jetty", name = "jetty-servlet", version.ref = "jetty" } jetty-servlets = { group = "org.eclipse.jetty", name = "jetty-servlets", version.ref = "jetty" } +rhino = { group = "org.mozilla", name = "rhino", version.ref ="rhino"} snmp4j = { group = "org.snmp4j", name = "snmp4j", version.ref = "snmp4j"} # test framework diff --git a/libraries/core/common/src/main/kotlin/com/mussonindustrial/embr/common/reflect/ReflectUtils.kt b/libraries/core/common/src/main/kotlin/com/mussonindustrial/embr/common/reflect/ReflectUtils.kt index 7bab80f6..6e818d62 100644 --- a/libraries/core/common/src/main/kotlin/com/mussonindustrial/embr/common/reflect/ReflectUtils.kt +++ b/libraries/core/common/src/main/kotlin/com/mussonindustrial/embr/common/reflect/ReflectUtils.kt @@ -10,10 +10,19 @@ fun T.getPrivateProperty(variableName: String): Any? { } } -fun T.getPrivateMethod(methodName: String, vararg params: Class<*> = arrayOf()): Method { - return javaClass.getDeclaredMethod(methodName, *params).let { method -> - method.trySetAccessible() - return@let method +fun Any.getPrivateMethod(methodName: String, vararg params: Class<*>): Method { + val clazz = + when (this) { + is Class<*> -> this + else -> this.javaClass + } + return clazz.getDeclaredMethod(methodName, *params).apply { trySetAccessible() } +} + +fun T.setPrivateProperty(variableName: String, value: Any?) { + javaClass.getDeclaredField(variableName).let { field -> + field.trySetAccessible() + field.set(this, value) } } diff --git a/libraries/javascript/perspective-client/src/globals/globals.ts b/libraries/javascript/perspective-client/src/globals/globals.ts index 38c90ebb..d82f5d39 100644 --- a/libraries/javascript/perspective-client/src/globals/globals.ts +++ b/libraries/javascript/perspective-client/src/globals/globals.ts @@ -1,4 +1,5 @@ export type EmbrGlobals = { + modules: Record scripting: ScriptingProperties } @@ -14,6 +15,7 @@ declare global { function createDefaultGlobals(): EmbrGlobals { window.__embrGlobals = { + modules: {}, scripting: { globals: {}, }, diff --git a/libraries/javascript/perspective-client/src/index.ts b/libraries/javascript/perspective-client/src/index.ts index 16dde5e5..aca76621 100644 --- a/libraries/javascript/perspective-client/src/index.ts +++ b/libraries/javascript/perspective-client/src/index.ts @@ -1,5 +1,6 @@ export * from './components' export * from './hooks' +export * from './globals' export * from './scripting' export * from './transforms' export * from './utils' diff --git a/libraries/javascript/perspective-client/src/utils/waitForClientStore/waitForClientStore.ts b/libraries/javascript/perspective-client/src/utils/waitForClientStore/waitForClientStore.ts index 85c7a9ab..4e038f34 100644 --- a/libraries/javascript/perspective-client/src/utils/waitForClientStore/waitForClientStore.ts +++ b/libraries/javascript/perspective-client/src/utils/waitForClientStore/waitForClientStore.ts @@ -7,12 +7,12 @@ import { getClientStore } from '../index' export default function waitForClientStore( callback: (clientStore: ClientStore) => void ) { - setTimeout(function () { + requestAnimationFrame(() => { const clientStore = getClientStore() if (clientStore) { callback(clientStore) } else { waitForClientStore(callback) } - }, 100) + }) } diff --git a/modules/periscope/common/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/Meta.kt b/modules/periscope/common/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/Meta.kt index 3d12729e..ef825715 100644 --- a/modules/periscope/common/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/Meta.kt +++ b/modules/periscope/common/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/Meta.kt @@ -8,7 +8,7 @@ object Meta { const val BUNDLE_PREFIX = "periscope" fun addI18NBundle() { - BundleUtil.get().addBundle(BUNDLE_PREFIX, Meta::class.java, "localization") + BundleUtil.get().addBundle(BUNDLE_PREFIX, this::class.java.classLoader, "localization") } fun removeI18NBundle() { diff --git a/modules/periscope/common/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/component/embedding/React.kt b/modules/periscope/common/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/component/embedding/React.kt new file mode 100644 index 00000000..8241d169 --- /dev/null +++ b/modules/periscope/common/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/component/embedding/React.kt @@ -0,0 +1,30 @@ +package com.mussonindustrial.ignition.embr.periscope.component.embedding + +import com.inductiveautomation.perspective.common.api.ComponentDescriptor +import com.inductiveautomation.perspective.common.api.ComponentDescriptorImpl +import com.mussonindustrial.embr.perspective.common.component.PaletteEntry +import com.mussonindustrial.embr.perspective.common.component.PerspectiveComponent +import com.mussonindustrial.embr.perspective.common.component.addPaletteEntry +import com.mussonindustrial.ignition.embr.periscope.Meta.MODULE_ID +import com.mussonindustrial.ignition.embr.periscope.PeriscopeComponents + +class React { + companion object : PerspectiveComponent { + override val id: String = "embr.periscope.embedding.react" + + private val VARIANT_BASE = + PaletteEntry(this::class.java, id, "base", "React", "React component.") + + override val descriptor: ComponentDescriptor = + ComponentDescriptorImpl.ComponentBuilder.newBuilder() + .setPaletteCategory("Embedding +") + .setId(id) + .setModuleId(MODULE_ID) + .setSchema(schema) + .setName("React") + .addPaletteEntry(VARIANT_BASE) + .setDefaultMetaName("React") + .setResources(PeriscopeComponents.BROWSER_RESOURCES) + .build() + } +} diff --git a/modules/periscope/common/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/resources/TypeScriptResource.kt b/modules/periscope/common/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/resources/TypeScriptResource.kt new file mode 100644 index 00000000..c105b43f --- /dev/null +++ b/modules/periscope/common/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/resources/TypeScriptResource.kt @@ -0,0 +1,21 @@ +package com.mussonindustrial.ignition.embr.periscope.resources + +import com.inductiveautomation.ignition.common.project.resource.ProjectResource +import com.inductiveautomation.ignition.common.project.resource.ResourceType +import com.mussonindustrial.ignition.embr.periscope.Meta + +data class TypeScriptResource(val source: String, val compiled: String) { + + companion object { + val type = ResourceType(Meta.MODULE_ID, "typescript") + const val SOURCE_KEY = "source.tsx" + const val CLIENT_KEY = "client.js" + + fun fromResource(resource: ProjectResource): TypeScriptResource { + return TypeScriptResource( + resource.getData(SOURCE_KEY)?.decodeToString() ?: "", + resource.getData(CLIENT_KEY)?.decodeToString() ?: "", + ) + } + } +} diff --git a/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-closed-disabled.svg b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-closed-disabled.svg new file mode 100644 index 00000000..39a32d7b --- /dev/null +++ b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-closed-disabled.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-closed-selected.svg b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-closed-selected.svg new file mode 100644 index 00000000..ceb22bc9 --- /dev/null +++ b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-closed-selected.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-closed.svg b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-closed.svg new file mode 100644 index 00000000..39a32d7b --- /dev/null +++ b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-closed.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-opened-selected.svg b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-opened-selected.svg new file mode 100644 index 00000000..215baa0d --- /dev/null +++ b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-opened-selected.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-opened.svg b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-opened.svg new file mode 100644 index 00000000..6bf493eb --- /dev/null +++ b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/folder-opened.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/schema-folder-disabled.svg b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/schema-folder-disabled.svg new file mode 100644 index 00000000..904d6bad --- /dev/null +++ b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/schema-folder-disabled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/schema-folder-selected.svg b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/schema-folder-selected.svg new file mode 100644 index 00000000..a5ecbec2 --- /dev/null +++ b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/schema-folder-selected.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/schema-folder.svg b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/schema-folder.svg new file mode 100644 index 00000000..904d6bad --- /dev/null +++ b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/schema-folder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/tsx-disabled.svg b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/tsx-disabled.svg new file mode 100644 index 00000000..df2fa5a2 --- /dev/null +++ b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/tsx-disabled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/tsx-selected.svg b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/tsx-selected.svg new file mode 100644 index 00000000..4da75001 --- /dev/null +++ b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/tsx-selected.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/tsx.svg b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/tsx.svg new file mode 100644 index 00000000..df2fa5a2 --- /dev/null +++ b/modules/periscope/common/src/main/resources/com/mussonindustrial/ignition/embr/periscope/images/svgicons/tsx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/periscope/common/src/main/resources/localization.properties b/modules/periscope/common/src/main/resources/localization.properties new file mode 100644 index 00000000..1d9cba51 --- /dev/null +++ b/modules/periscope/common/src/main/resources/localization.properties @@ -0,0 +1,22 @@ +script.common-param.sessionId=Optional. Identifier of the session to target. If omitted, current session will be used automatically. +script.common-param.pageId=Optional. Identifier of the page to target. If omitted, current page will be used automatically. + +script.runJavaScriptBlocking.desc=Run JavaScript code on the client and block for the result. +script.runJavaScriptBlocking.param.function=JavaScript function to run. Should be formatted as an arrow function. You may return a promise and resolve it later.

Example:
() => console.log('runJavaScriptBlocking') +script.runJavaScriptBlocking.param.args=Optional. Dictionary of arguments to use when calling the function. The keys of the dictionary should match the names of the arrow function arguments. +script.runJavaScriptBlocking.returns=Return value of JavaScript function. + +script.runJavaScriptAsync.desc=Asynchronously run JavaScript code on the client. +script.runJavaScriptAsync.param.function=JavaScript function to run. Should be formatted as an arrow function. You may return a promise and resolve it later.

Example:`() => console.log('runJavaScriptAsync') +script.runJavaScriptAsync.param.args=Optional. Dictionary of arguments to use when calling the function. The keys of the dictionary should match the names of the arrow function arguments. +script.runJavaScriptAsync.param.callback=Function to run once the function has returned/resolved. Should take a single parameter containing the result of the function. + +typescript.noun=TypeScript Module +typescript.nouns=TypeScript Modules +typescript.noun-long=TypeScript Module +typescript.nouns-long=TypeScript Modules +typescript.error.notFound=TypeScript Module not found. +typescript.error.existingResource=TypeScript Module '%s' is already defined +typescript.error.emptyResource=TypeScript Module name must not be empty +typescript.action.new=New TypeScript Module +typescript.action.action.new.defaultName=NewTypeScriptModule \ No newline at end of file diff --git a/modules/periscope/common/src/main/resources/schemas/components/embr.periscope.embedding.react/props.json b/modules/periscope/common/src/main/resources/schemas/components/embr.periscope.embedding.react/props.json new file mode 100644 index 00000000..8957111b --- /dev/null +++ b/modules/periscope/common/src/main/resources/schemas/components/embr.periscope.embedding.react/props.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "component": { + "type": "string", + "description": "Component Path.", + "extension": { + "suggestion-source": "embr-periscope-typescript-resource" + } + }, + "props": { + "type": "object" + }, + "events": { + "type": "object", + "description": "Component events.", + "properties": { + "dom": { + "$ref": "urn:ignition-schema:schemas/components/events-dom.json" + }, + "lifecycle": { + "$ref": "urn:ignition-schema:schemas/components/events-lifecycle.json" + }, + "target": { + "type": "object", + "properties": { + "lifecycle": { + "$ref": "urn:ignition-schema:schemas/components/events-lifecycle.json" + } + } + } + } + }, + "style": { + "$ref": "urn:ignition-schema:schemas/style-properties.schema.json", + "default": { + "classes": "", + "overflow": "auto" + } + } + }, + "type": "object", + "required": ["component"] +} \ No newline at end of file diff --git a/modules/periscope/common/src/main/resources/schemas/components/embr.periscope.embedding.react/variants/base.props.json b/modules/periscope/common/src/main/resources/schemas/components/embr.periscope.embedding.react/variants/base.props.json new file mode 100644 index 00000000..1de2a9a1 --- /dev/null +++ b/modules/periscope/common/src/main/resources/schemas/components/embr.periscope.embedding.react/variants/base.props.json @@ -0,0 +1,7 @@ +{ + "component": "", + "props": {}, + "style": { + "classes": "" + } +} \ No newline at end of file diff --git a/modules/periscope/designer/build.gradle.kts b/modules/periscope/designer/build.gradle.kts index 5b706491..9482d4ee 100644 --- a/modules/periscope/designer/build.gradle.kts +++ b/modules/periscope/designer/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { modlImplementation(projects.libraries.core.designer) compileOnly(projects.libraries.perspective.common) modlImplementation(projects.libraries.perspective.designer) + modlImplementation(projects.modules.periscope.tsCompiler) compileOnly(projects.modules.periscope.common) } \ No newline at end of file diff --git a/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeDesignerContext.kt b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeDesignerContext.kt index 39451a40..72bb9fc8 100644 --- a/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeDesignerContext.kt +++ b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeDesignerContext.kt @@ -1,23 +1,49 @@ package com.mussonindustrial.ignition.embr.periscope import com.inductiveautomation.ignition.designer.model.DesignerContext +import com.inductiveautomation.perspective.designer.DesignerHook +import com.inductiveautomation.perspective.designer.PerspectiveNavNode import com.inductiveautomation.perspective.designer.api.PerspectiveDesignerInterface +import com.mussonindustrial.embr.common.reflect.getPrivateProperty import com.mussonindustrial.embr.designer.EmbrDesignerContext import com.mussonindustrial.embr.designer.EmbrDesignerContextImpl import com.mussonindustrial.embr.perspective.designer.component.asDesignerComponent import com.mussonindustrial.embr.perspective.designer.component.registerComponent import com.mussonindustrial.embr.perspective.designer.component.removeComponent import com.mussonindustrial.ignition.embr.periscope.component.ComponentIdSuggestionSource +import com.mussonindustrial.ignition.embr.periscope.component.TypeScriptResourceSuggestionSource import com.mussonindustrial.ignition.embr.periscope.component.embedding.* +import com.mussonindustrial.ignition.embr.periscope.resources.typescript.TypeScriptResourceWorkspace +import com.teamdev.jxbrowser.engine.Engine -class PeriscopeDesignerContext(private val context: DesignerContext) : +class PeriscopeDesignerContext(val context: DesignerContext) : EmbrDesignerContext by EmbrDesignerContextImpl(context) { companion object { lateinit var instance: PeriscopeDesignerContext } val perspectiveDesignerInterface: PerspectiveDesignerInterface - private val componentIdSuggestionSource: ComponentIdSuggestionSource + val perspectiveNavNode: PerspectiveNavNode + val jxBrowserEngine: Engine + + init { + instance = this + perspectiveDesignerInterface = PerspectiveDesignerInterface.get(context) + perspectiveNavNode = + DesignerHook.get(context).getPrivateProperty("navNode") as PerspectiveNavNode + jxBrowserEngine = DesignerHook.get(context).workspace.engine + perspectiveDesignerInterface.suggestionSourceRegistry.apply { + registerSuggestionSource( + ComponentIdSuggestionSource.ID, + ComponentIdSuggestionSource(this@PeriscopeDesignerContext), + ) + registerSuggestionSource( + TypeScriptResourceSuggestionSource.ID, + TypeScriptResourceSuggestionSource(this@PeriscopeDesignerContext), + ) + } + } + private val components = listOf( EmbeddedView.asDesignerComponent(), @@ -25,17 +51,10 @@ class PeriscopeDesignerContext(private val context: DesignerContext) : JsonView.asDesignerComponent(), Portal.asDesignerComponent(), Swiper.asDesignerComponent(), + React.asDesignerComponent(), ) - init { - instance = this - perspectiveDesignerInterface = PerspectiveDesignerInterface.get(context) - componentIdSuggestionSource = ComponentIdSuggestionSource(this) - perspectiveDesignerInterface.suggestionSourceRegistry.registerSuggestionSource( - ComponentIdSuggestionSource.ID, - componentIdSuggestionSource, - ) - } + private val workspaces = listOf(TypeScriptResourceWorkspace(context, perspectiveNavNode)) fun registerComponents() { components.forEach { perspectiveDesignerInterface.registerComponent(it) } @@ -44,4 +63,8 @@ class PeriscopeDesignerContext(private val context: DesignerContext) : fun removeComponents() { components.forEach { perspectiveDesignerInterface.removeComponent(it) } } + + fun registerResourceWorkspaces() { + workspaces.forEach { registerResourceWorkspace(it) } + } } diff --git a/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeDesignerHook.kt b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeDesignerHook.kt index f8d5293f..1a4a98f3 100644 --- a/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeDesignerHook.kt +++ b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeDesignerHook.kt @@ -1,5 +1,6 @@ package com.mussonindustrial.ignition.embr.periscope +import com.inductiveautomation.ignition.common.BundleUtil import com.inductiveautomation.ignition.common.licensing.LicenseState import com.inductiveautomation.ignition.designer.model.AbstractDesignerModuleHook import com.inductiveautomation.ignition.designer.model.DesignerContext @@ -23,7 +24,7 @@ class PeriscopeDesignerHook : AbstractDesignerModuleHook() { override fun startup(context: DesignerContext, activationState: LicenseState) { logger.debug("Embr-Periscope module started.") this.context = PeriscopeDesignerContext(context) - Meta.addI18NBundle() + BundleUtil.get().addBundle(Meta.BUNDLE_PREFIX, this::class.java.classLoader, "localization") val pdi: PerspectiveDesignerInterface = PerspectiveDesignerInterface.get(context) @@ -36,6 +37,9 @@ class PeriscopeDesignerHook : AbstractDesignerModuleHook() { logger.debug("Registering components...") this.context.registerComponents() + + logger.debug("Registering resource editors...") + this.context.registerResourceWorkspaces() } override fun shutdown() { diff --git a/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/component/TypeScriptResourceSuggestionSource.kt b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/component/TypeScriptResourceSuggestionSource.kt new file mode 100644 index 00000000..4c52bccf --- /dev/null +++ b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/component/TypeScriptResourceSuggestionSource.kt @@ -0,0 +1,41 @@ +package com.mussonindustrial.ignition.embr.periscope.component + +import com.inductiveautomation.ignition.client.jsonedit.DocumentNode +import com.inductiveautomation.ignition.common.gson.JsonElement +import com.inductiveautomation.ignition.common.gson.JsonPrimitive +import com.inductiveautomation.ignition.common.jsonschema.JsonSchema +import com.inductiveautomation.perspective.designer.api.SuggestionSource +import com.mussonindustrial.ignition.embr.periscope.PeriscopeDesignerContext +import com.mussonindustrial.ignition.embr.periscope.resources.TypeScriptResource +import java.util.concurrent.CompletableFuture + +class TypeScriptResourceSuggestionSource(private val context: PeriscopeDesignerContext) : + SuggestionSource { + + companion object { + const val ID = "embr-periscope-typescript-resource" + } + + override fun getSuggestions( + node: DocumentNode?, + schema: JsonSchema?, + ): CompletableFuture> { + + val project = context.project + if (project == null) { + return CompletableFuture.completedFuture(mutableMapOf()) + } + + val resources = context.project!!.getResourcesOfType(TypeScriptResource.type) + val resourcePaths = + resources + .associate { + it.resourcePath.path.toString() to + JsonPrimitive(it.resourcePath.path.toString()) as JsonElement + } + .toSortedMap() + .toMutableMap() + + return CompletableFuture.completedFuture(resourcePaths) + } +} diff --git a/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/icons/PeriscopeIcons.kt b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/icons/PeriscopeIcons.kt new file mode 100644 index 00000000..4dd0cfb6 --- /dev/null +++ b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/icons/PeriscopeIcons.kt @@ -0,0 +1,11 @@ +package com.mussonindustrial.ignition.embr.periscope.icons + +import com.inductiveautomation.ignition.designer.navtree.icon.InteractiveSvgIcon +import com.mussonindustrial.ignition.embr.periscope.Meta + +object PeriscopeIcons { + private fun icon(name: String) = InteractiveSvgIcon(Meta::class.java, "images/svgicons/$name") + + val tsx = icon("tsx.svg") + val folderClosed = icon("folder-closed.svg") +} diff --git a/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/navtree/model/HiddenActionResourceNode.kt b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/navtree/model/HiddenActionResourceNode.kt new file mode 100644 index 00000000..4a29ff57 --- /dev/null +++ b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/navtree/model/HiddenActionResourceNode.kt @@ -0,0 +1,47 @@ +package com.mussonindustrial.ignition.embr.periscope.navtree.model + +import com.inductiveautomation.ignition.common.project.resource.ProjectResource +import com.inductiveautomation.ignition.designer.model.DesignerContext +import com.inductiveautomation.ignition.designer.navtree.model.AbstractNavTreeNode +import com.inductiveautomation.ignition.designer.tabbedworkspace.ResourceNode +import com.inductiveautomation.ignition.designer.tabbedworkspace.TabbedResourceWorkspace +import javax.swing.JMenuItem +import javax.swing.JPopupMenu +import javax.swing.tree.TreePath + +abstract class HiddenActionResourceNode( + context: DesignerContext, + workspace: TabbedResourceWorkspace, + resource: ProjectResource, +) : ResourceNode(context, workspace, resource) { + + fun JPopupMenu.item(text: String, action: (JMenuItem) -> Unit): JMenuItem { + val item = JMenuItem(text) + item.addActionListener { action(item) } + add(item) + return item + } + + override fun initPopupMenu( + menu: JPopupMenu, + paths: Array, + selection: List, + modifiers: Int, + ) { + super.initPopupMenu(menu, paths, selection, modifiers) + if ((modifiers and SHIFT_MASK) == SHIFT_MASK) { + addShiftClickMenuItems(menu, paths, selection, modifiers) + } + } + + abstract fun addShiftClickMenuItems( + menu: JPopupMenu, + paths: Array, + selection: List, + modifiers: Int, + ) + + companion object { + const val SHIFT_MASK: Int = 64 + } +} diff --git a/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/navtree/model/TypeScriptResourceFolder.kt b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/navtree/model/TypeScriptResourceFolder.kt new file mode 100644 index 00000000..77bce725 --- /dev/null +++ b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/navtree/model/TypeScriptResourceFolder.kt @@ -0,0 +1,27 @@ +package com.mussonindustrial.ignition.embr.periscope.navtree.model + +import com.inductiveautomation.ignition.common.project.resource.ProjectResource +import com.inductiveautomation.ignition.designer.model.DesignerContext +import com.inductiveautomation.ignition.designer.navtree.model.AbstractNavTreeNode +import com.inductiveautomation.ignition.designer.tabbedworkspace.ResourceFolderNode +import com.inductiveautomation.ignition.designer.tabbedworkspace.TabbedResourceWorkspace + +class TypeScriptResourceFolder : ResourceFolderNode { + constructor( + context: DesignerContext, + workspace: TabbedResourceWorkspace, + ) : super(context, workspace) + + constructor( + context: DesignerContext, + workspace: TabbedResourceWorkspace, + resource: ProjectResource, + ) : super(context, workspace, resource) + + override fun createChildNode(resource: ProjectResource): AbstractNavTreeNode? { + return when (resource.isFolder) { + true -> TypeScriptResourceFolder(context, workspace, resource) + false -> TypeScriptResourceNode(context, workspace, resource) + } + } +} diff --git a/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/navtree/model/TypeScriptResourceNode.kt b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/navtree/model/TypeScriptResourceNode.kt new file mode 100644 index 00000000..2f5b4903 --- /dev/null +++ b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/navtree/model/TypeScriptResourceNode.kt @@ -0,0 +1,95 @@ +package com.mussonindustrial.ignition.embr.periscope.navtree.model + +import com.inductiveautomation.ignition.common.project.resource.ProjectResource +import com.inductiveautomation.ignition.designer.model.DesignerContext +import com.inductiveautomation.ignition.designer.navtree.model.AbstractNavTreeNode +import com.inductiveautomation.ignition.designer.tabbedworkspace.TabbedResourceWorkspace +import com.mussonindustrial.embr.common.logging.getLogger +import com.mussonindustrial.ignition.embr.periscope.icons.PeriscopeIcons +import com.mussonindustrial.ignition.embr.periscope.resources.TypeScriptResource +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection +import java.awt.event.InputEvent +import java.awt.event.KeyEvent +import javax.swing.JPopupMenu +import javax.swing.KeyStroke +import javax.swing.tree.TreePath + +class TypeScriptResourceNode( + context: DesignerContext, + workspace: TabbedResourceWorkspace, + val resource: ProjectResource, +) : HiddenActionResourceNode(context, workspace, resource) { + + private val logger = this.getLogger() + + override fun initPopupMenu( + menu: JPopupMenu, + paths: Array, + selection: List, + modifiers: Int, + ) { + + menu + .item("Recompile") { logger.info("TODO: Implement recompile from menu.") } + .apply { + icon = PeriscopeIcons.tsx + mnemonic = KeyEvent.VK_R + accelerator = KeyStroke.getKeyStroke(KeyEvent.VK_R, InputEvent.CTRL_DOWN_MASK) + } + + menu + .item("Format") { logger.info("TODO: Implement format from menu.") } + .apply { + icon = PeriscopeIcons.tsx + mnemonic = KeyEvent.VK_F + accelerator = + KeyStroke.getKeyStroke( + KeyEvent.VK_F, + InputEvent.CTRL_DOWN_MASK or InputEvent.ALT_DOWN_MASK, + ) + } + + menu.addSeparator() + + super.initPopupMenu(menu, paths, selection, modifiers) + } + + override fun addShiftClickMenuItems( + menu: JPopupMenu, + paths: Array, + selection: List, + modifiers: Int, + ) { + + menu.addSeparator() + + menu + .item("Copy Source") { + val typescriptResource = TypeScriptResource.fromResource(resource) + Toolkit.getDefaultToolkit() + .systemClipboard + .setContents(StringSelection(typescriptResource.source), null) + } + .apply { icon = PeriscopeIcons.tsx } + + menu + .item("Paste Source") { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val contents = clipboard.getContents(null) + + val text = + if (contents?.isDataFlavorSupported(DataFlavor.stringFlavor) == true) + contents.getTransferData(DataFlavor.stringFlavor) as String + else null + + if (text == null) return@item + + context.project?.createOrModify(resource.resourcePath) { builder -> + builder.putData(TypeScriptResource.SOURCE_KEY, text.toByteArray()) + } + } + .apply { icon = PeriscopeIcons.tsx } + } +} diff --git a/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/resources/typescript/TypeScriptResourceEditor.kt b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/resources/typescript/TypeScriptResourceEditor.kt new file mode 100644 index 00000000..74a02e03 --- /dev/null +++ b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/resources/typescript/TypeScriptResourceEditor.kt @@ -0,0 +1,111 @@ +package com.mussonindustrial.ignition.embr.periscope.resources.typescript + +import com.inductiveautomation.ignition.common.project.resource.ProjectResource +import com.inductiveautomation.ignition.common.project.resource.ProjectResourceBuilder +import com.inductiveautomation.ignition.common.project.resource.ResourcePath +import com.inductiveautomation.ignition.designer.ext.onClick +import com.inductiveautomation.ignition.designer.gui.tools.DisplayTrackingSyntaxTextArea +import com.inductiveautomation.ignition.designer.tabbedworkspace.ResourceEditor +import com.inductiveautomation.ignition.designer.tabbedworkspace.TabbedResourceWorkspace +import com.mussonindustrial.embr.common.logging.getLoggerEx +import com.mussonindustrial.ignition.embr.periscope.PeriscopeDesignerContext +import com.mussonindustrial.ignition.embr.periscope.resources.TypeScriptResource +import com.mussonindustrial.ignition.embr.periscope.typescript.TypeScriptCompiler +import java.awt.Font +import java.awt.Label +import javax.swing.JButton +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener +import net.miginfocom.swing.MigLayout +import org.fife.rsta.ac.LanguageSupportFactory +import org.fife.rsta.ac.js.JavaScriptLanguageSupport +import org.fife.ui.rsyntaxtextarea.SyntaxConstants +import org.fife.ui.rtextarea.RTextScrollPane +import org.json.JSONException + +class TypeScriptResourceEditor(workspace: TabbedResourceWorkspace?, path: ResourcePath?) : + ResourceEditor(workspace, path) { + + private val logger = this.getLoggerEx() + private val context = PeriscopeDesignerContext.instance + + private var resource: TypeScriptResource? = null + private lateinit var textArea: DisplayTrackingSyntaxTextArea + + val compiler = TypeScriptCompiler(context.jxBrowserEngine) + + override fun init(resource: TypeScriptResource) { + this.resource = resource + + this.removeAll() + this.layout = MigLayout("", "[]", "[25px!][]") + + val title = Label(this.tabTitle) + title.font = Font(Font.DIALOG_INPUT, Font.PLAIN, 15) + this.add(title, "cell 0 0, span") + + val format = + JButton("Format").onClick { + val result = compiler.format(textArea.text, textArea.caretPosition) + if (result != null) { + textArea.text = result.formatted + textArea.caretPosition = result.cursorOffset + this.commit() + } + } + this.add(format, "cell 1 0, span") + + val lsf = LanguageSupportFactory.get() + val support = + lsf.getSupportFor(SyntaxConstants.SYNTAX_STYLE_JAVASCRIPT) as JavaScriptLanguageSupport + support.isStrictMode = true + + textArea = DisplayTrackingSyntaxTextArea(resource.source) + LanguageSupportFactory.get().register(textArea) + textArea.apply { + syntaxEditingStyle = SyntaxConstants.SYNTAX_STYLE_TYPESCRIPT + isCodeFoldingEnabled = true + tabSize = 2 + tabsEmulated = true + convertTabsToSpaces() + } + + val sp = RTextScrollPane(textArea) + this.add(sp, "cell 0 1, span, growx, growy, push") + + textArea.document.addDocumentListener(SimpleDocumentListener { this.commit() }) + } + + override fun getObjectForSave(): TypeScriptResource { + val result = compiler.compile(textArea.text, resourcePath.path.toString()) + + return TypeScriptResource( + textArea.text ?: "", + result?.outputText ?: "console.error('Failed to compile.')", + ) + } + + override fun deserialize(resource: ProjectResource): TypeScriptResource { + return TypeScriptResource.fromResource(resource) + } + + @Throws(JSONException::class) + override fun serializeResource(builder: ProjectResourceBuilder, resource: TypeScriptResource) { + builder.putData(TypeScriptResource.SOURCE_KEY, resource.source.encodeToByteArray()) + builder.putData(TypeScriptResource.CLIENT_KEY, resource.compiled.encodeToByteArray()) + } +} + +class SimpleDocumentListener(val block: (DocumentEvent) -> Unit) : DocumentListener { + override fun insertUpdate(event: DocumentEvent) { + block(event) + } + + override fun removeUpdate(event: DocumentEvent) { + block(event) + } + + override fun changedUpdate(event: DocumentEvent) { + block(event) + } +} diff --git a/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/resources/typescript/TypeScriptResourceWorkspace.kt b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/resources/typescript/TypeScriptResourceWorkspace.kt new file mode 100644 index 00000000..d601252f --- /dev/null +++ b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/resources/typescript/TypeScriptResourceWorkspace.kt @@ -0,0 +1,113 @@ +package com.mussonindustrial.ignition.embr.periscope.resources.typescript + +import com.inductiveautomation.ignition.common.BundleUtil +import com.inductiveautomation.ignition.common.model.ApplicationScope +import com.inductiveautomation.ignition.common.project.resource.ProjectResourceBuilder +import com.inductiveautomation.ignition.common.project.resource.ResourcePath +import com.inductiveautomation.ignition.designer.model.DesignerContext +import com.inductiveautomation.ignition.designer.navtree.icon.InteractiveSvgIcon +import com.inductiveautomation.ignition.designer.navtree.model.AbstractNavTreeNode +import com.inductiveautomation.ignition.designer.navtree.model.MutableNavTreeNode +import com.inductiveautomation.ignition.designer.tabbedworkspace.* +import com.inductiveautomation.ignition.designer.workspacewelcome.RecentlyModifiedTablePanel +import com.inductiveautomation.ignition.designer.workspacewelcome.ResourceBuilderDelegate +import com.inductiveautomation.ignition.designer.workspacewelcome.ResourceBuilderPanel +import com.inductiveautomation.ignition.designer.workspacewelcome.WorkspaceWelcomePanel +import com.mussonindustrial.embr.common.logging.getLogger +import com.mussonindustrial.ignition.embr.periscope.Meta +import com.mussonindustrial.ignition.embr.periscope.icons.PeriscopeIcons +import com.mussonindustrial.ignition.embr.periscope.navtree.model.TypeScriptResourceFolder +import com.mussonindustrial.ignition.embr.periscope.resources.TypeScriptResource +import java.util.* +import java.util.function.Consumer +import javax.swing.JComponent +import javax.swing.JPopupMenu +import org.json.JSONException + +class TypeScriptResourceWorkspace( + context: DesignerContext, + private val parent: MutableNavTreeNode, +) : + TabbedResourceWorkspace( + context, + ResourceDescriptor.builder() + .resourceType(TypeScriptResource.type) + .rootFolderText("TypeScript") + .nounKey("periscope.typescript.noun") + .rootIcon(PeriscopeIcons.folderClosed) + .icon(PeriscopeIcons.tsx) + .navTreeLocation(9999999) + .scope(ApplicationScope.GATEWAY) + .build(), + ) { + + private val logger = this.getLogger() + private val resourceConstructor = + Consumer { builder: ProjectResourceBuilder -> + try { + builder.putData(TypeScriptResource.SOURCE_KEY, ByteArray(0)) + builder.putData(TypeScriptResource.CLIENT_KEY, ByteArray(0)) + } catch (e: JSONException) { + logger.error("Error creating new TypeScript Module.", e) + } + } + + override fun getKey(): String { + return TypeScriptResource.type.typeId + } + + public override fun getNavTreeNodeParent(): MutableNavTreeNode { + return this.parent + } + + override fun createRootNavTreeNode(): AbstractNavTreeNode { + return TypeScriptResourceFolder(context, this) + } + + override fun newResourceEditor(resourcePath: ResourcePath): ResourceEditor<*> { + return TypeScriptResourceEditor(this, resourcePath) + } + + override fun addNewResourceActions(folderNode: ResourceFolderNode, menu: JPopupMenu) { + menu.add( + object : NewResourceAction(this, folderNode, resourceConstructor) { + init { + putValue(NAME, "New TypeScript Module") + putValue(SMALL_ICON, PeriscopeIcons.tsx) + } + + override fun newResourceName() = "NewTypeScriptModule" + } + ) + } + + override fun createWorkspaceHomeTab(): Optional { + return Optional.of( + object : WorkspaceWelcomePanel(BundleUtil.i18n("periscope.typescript.nouns-long")) { + override fun createPanels(): List { + return listOf( + ResourceBuilderPanel( + context, + BundleUtil.i18n("periscope.typescript.noun"), + TypeScriptResource.type.rootPath(), + listOf( + ResourceBuilderDelegate.build( + BundleUtil.i18n("periscope.typescript.noun-long"), + InteractiveSvgIcon(Meta::class.java, "images/svgicons/tsx.svg"), + resourceConstructor, + ) + ), + this@TypeScriptResourceWorkspace::open, + ), + RecentlyModifiedTablePanel( + context, + TypeScriptResource.type, + BundleUtil.i18n("periscope.typescript.nouns-long"), + this@TypeScriptResourceWorkspace::open, + ), + ) + } + } + ) + } +} diff --git a/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/typescript/TypeScriptCompiler.kt b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/typescript/TypeScriptCompiler.kt new file mode 100644 index 00000000..7841314a --- /dev/null +++ b/modules/periscope/designer/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/typescript/TypeScriptCompiler.kt @@ -0,0 +1,109 @@ +package com.mussonindustrial.ignition.embr.periscope.typescript + +import com.mussonindustrial.embr.common.logging.getLoggerEx +import com.teamdev.jxbrowser.browser.Browser +import com.teamdev.jxbrowser.engine.Engine +import com.teamdev.jxbrowser.js.JsArray +import com.teamdev.jxbrowser.js.JsObject +import com.teamdev.jxbrowser.js.JsPromise +import java.io.InputStream +import java.util.concurrent.CompletableFuture + +class TypeScriptCompiler(engine: Engine) { + + val logger = this.getLoggerEx() + + private val resourcePath = "/static/embr-periscope-ts-compiler.js" + private val compilerModule = "EmbrPeriscopeTsCompiler" + + val browser: Browser = + engine.newBrowser().apply { + navigation().loadUrl("about:blank") + val jsStream: InputStream? = object {}.javaClass.getResourceAsStream(resourcePath) + if (jsStream == null) { + logger.error("Could not find $resourcePath on classpath") + } + val jsSource = jsStream?.bufferedReader().use { it?.readText() } + if (jsSource != null) { + mainFrame().get().executeJavaScript(jsSource) + } + } + val frame = browser.mainFrame().get() + + fun compile(input: String, modulePath: String): CompilationResult? { + val compiler = frame.executeJavaScript(compilerModule)!! + val result = compiler.call("compile", input, modulePath) + + return try { + CompilationResult(result) + } catch (_: Exception) { + null + } + } + + class CompilationResult(val result: JsObject) { + + companion object { + private val requiredProperties = listOf("outputText", "diagnostics") + } + + init { + if (!result.propertyNames().containsAll(requiredProperties)) { + val missingProperties = requiredProperties.toSet().minus(result.propertyNames()) + throw Exception( + "CompilationResult is missing required properties: $missingProperties" + ) + } + } + + val outputText: String + get() = result.property("outputText").get() + + val sourceMapText: String + get() = result.property("sourceMapText").get() + + val diagnostics: JsArray? + get() = result.property("diagnostics").orElse(null) + } + + fun format(source: String, cursorPosition: Int): FormatResult? { + val compiler = frame.executeJavaScript(compilerModule)!! + val result = compiler.call("format", source, cursorPosition) + logger.debug("Format Result $result") + + val future = CompletableFuture() + + result + .then { it -> + logger.debug("Format Result Resolution $it") + val formatResult = it[0] as JsObject + future.complete(FormatResult(formatResult)) + } + .catchError { error -> + val errors = error.toList() + val text = errors.map { frame.json().stringify(it as JsObject) } + future.completeExceptionally(Exception(text.joinToString("::"))) + } + + return future.get() + } + + class FormatResult(val result: JsObject) { + companion object { + private val requiredProperties = listOf("formatted", "cursorOffset") + } + + init { + if (!result.propertyNames().containsAll(requiredProperties)) { + val missingProperties = requiredProperties.toSet().minus(result.propertyNames()) + throw Exception("FormatResult is missing required properties: $missingProperties") + } + } + + val formatted: String + get() = result.property("formatted").get() + + val cursorOffset: Int + get() = result.property("cursorOffset").get() + } +} diff --git a/modules/periscope/designer/src/main/resources/images/components/embr.periscope.embedding.react/component-disabled.svg b/modules/periscope/designer/src/main/resources/images/components/embr.periscope.embedding.react/component-disabled.svg new file mode 100644 index 00000000..5f3d573e --- /dev/null +++ b/modules/periscope/designer/src/main/resources/images/components/embr.periscope.embedding.react/component-disabled.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + diff --git a/modules/periscope/designer/src/main/resources/images/components/embr.periscope.embedding.react/component-selected.svg b/modules/periscope/designer/src/main/resources/images/components/embr.periscope.embedding.react/component-selected.svg new file mode 100644 index 00000000..40a25d54 --- /dev/null +++ b/modules/periscope/designer/src/main/resources/images/components/embr.periscope.embedding.react/component-selected.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + diff --git a/modules/periscope/designer/src/main/resources/images/components/embr.periscope.embedding.react/component.icon.svg b/modules/periscope/designer/src/main/resources/images/components/embr.periscope.embedding.react/component.icon.svg new file mode 100644 index 00000000..3e1a1369 --- /dev/null +++ b/modules/periscope/designer/src/main/resources/images/components/embr.periscope.embedding.react/component.icon.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + diff --git a/modules/periscope/designer/src/main/resources/images/components/embr.periscope.embedding.react/thumbnails/base.png b/modules/periscope/designer/src/main/resources/images/components/embr.periscope.embedding.react/thumbnails/base.png new file mode 100644 index 00000000..a00f4a6a Binary files /dev/null and b/modules/periscope/designer/src/main/resources/images/components/embr.periscope.embedding.react/thumbnails/base.png differ diff --git a/modules/periscope/gateway/build.gradle.kts b/modules/periscope/gateway/build.gradle.kts index 5b8a2bf6..b64ba2a6 100644 --- a/modules/periscope/gateway/build.gradle.kts +++ b/modules/periscope/gateway/build.gradle.kts @@ -10,4 +10,8 @@ dependencies { modlImplementation(projects.libraries.perspective.gateway) compileOnly(projects.modules.periscope.common) modlImplementation(projects.modules.periscope.web) + compileOnly(libs.jetty.server) + compileOnly(libs.jetty.servlet) + modlImplementation(libs.jetty.servlets) + modlImplementation(projects.libraries.core.servlets) } \ No newline at end of file diff --git a/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeGatewayContext.kt b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeGatewayContext.kt index aa072bdd..ea7de1e8 100644 --- a/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeGatewayContext.kt +++ b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeGatewayContext.kt @@ -1,9 +1,16 @@ package com.mussonindustrial.ignition.embr.periscope +import com.inductiveautomation.ignition.gateway.dataroutes.RouteAccessControl +import com.inductiveautomation.ignition.gateway.model.DiagnosticsManager import com.inductiveautomation.ignition.gateway.model.GatewayContext +import com.inductiveautomation.ignition.gateway.model.TelemetryManager import com.inductiveautomation.perspective.common.PerspectiveModule +import com.inductiveautomation.perspective.gateway.GatewayHook import com.inductiveautomation.perspective.gateway.api.PerspectiveContext +import com.inductiveautomation.perspective.gateway.api.SessionScope +import com.inductiveautomation.perspective.gateway.comm.Routes import com.inductiveautomation.perspective.gateway.model.PageModel +import com.mussonindustrial.embr.common.reflect.getPrivateMethod import com.mussonindustrial.embr.gateway.EmbrGatewayContext import com.mussonindustrial.embr.gateway.EmbrGatewayContextImpl import com.mussonindustrial.embr.perspective.common.component.addResourcesTo @@ -13,7 +20,10 @@ import com.mussonindustrial.embr.perspective.gateway.component.asGatewayComponen import com.mussonindustrial.embr.perspective.gateway.component.registerComponent import com.mussonindustrial.embr.perspective.gateway.component.removeComponent import com.mussonindustrial.embr.perspective.gateway.reflect.ViewLoader +import com.mussonindustrial.embr.servlets.ModuleServletManager import com.mussonindustrial.ignition.embr.periscope.component.embedding.* +import com.mussonindustrial.ignition.embr.periscope.resources.typescript.TypeScriptChangeListener +import java.util.EnumSet import java.util.WeakHashMap class PeriscopeGatewayContext(private val context: GatewayContext) : @@ -22,7 +32,8 @@ class PeriscopeGatewayContext(private val context: GatewayContext) : lateinit var instance: PeriscopeGatewayContext } - val perspectiveContext: PerspectiveContext + val servletManager = ModuleServletManager(context.webResourceManager, "/embr/periscope") + val perspectiveContext: GatewayHook.PerspectiveGatewayContext private val components = listOf( EmbeddedView.asGatewayComponent { EmbeddedViewModelDelegate(it) }, @@ -30,13 +41,17 @@ class PeriscopeGatewayContext(private val context: GatewayContext) : JsonView.asGatewayComponent { JsonViewModelDelegate(it) }, Portal.asGatewayComponent(), Swiper.asGatewayComponent { JavaScriptProxyableComponentModelDelegate(it) }, + React.asGatewayComponent { JavaScriptProxyableComponentModelDelegate(it) }, ) init { instance = this - perspectiveContext = PerspectiveContext.get(context) + perspectiveContext = + PerspectiveContext.get(context) as GatewayHook.PerspectiveGatewayContext } + private val projectLifecycles = + listOf(TypeScriptChangeListener(perspectiveContext, context.projectManager)) private val viewLoaders = WeakHashMap() fun getViewLoader(pageModel: PageModel): ViewLoader { @@ -72,4 +87,29 @@ class PeriscopeGatewayContext(private val context: GatewayContext) : it.moduleId() == PerspectiveModule.MODULE_ID } } + + fun startupProjectLifecycles() { + projectLifecycles.forEach { it.startup() } + } + + fun shutdownProjectLifecycles() { + projectLifecycles.forEach { it.shutdown() } + } + + fun removeServlets() { + servletManager.removeAllServlets() + } + + override fun getTelemetryManager(): TelemetryManager? { + return super.getTelemetryManager() + } + + override fun getDiagnosticsManager(): DiagnosticsManager? { + return super.getDiagnosticsManager() + } + + fun requireSession(scopes: EnumSet): RouteAccessControl { + val internal = Routes::class.java.getPrivateMethod("requireSession", EnumSet::class.java) + return internal.invoke(Routes::class.java, scopes) as RouteAccessControl + } } diff --git a/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeGatewayHook.kt b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeGatewayHook.kt index 6e9500c4..f0f04b39 100644 --- a/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeGatewayHook.kt +++ b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeGatewayHook.kt @@ -4,10 +4,12 @@ import com.inductiveautomation.ignition.common.BundleUtil import com.inductiveautomation.ignition.common.licensing.LicenseState import com.inductiveautomation.ignition.common.script.ScriptManager import com.inductiveautomation.ignition.common.script.hints.PropertiesFileDocProvider +import com.inductiveautomation.ignition.gateway.dataroutes.RouteGroup import com.inductiveautomation.ignition.gateway.model.AbstractGatewayModuleHook import com.inductiveautomation.ignition.gateway.model.GatewayContext import com.mussonindustrial.ignition.embr.periscope.Meta.SHORT_MODULE_ID -import com.mussonindustrial.ignition.embr.periscope.component.embedding.* +import com.mussonindustrial.ignition.embr.periscope.handlers.ClientResourceJavaScriptHandler +import com.mussonindustrial.ignition.embr.periscope.handlers.ClientResourceManifestHandler import com.mussonindustrial.ignition.embr.periscope.scripting.JavaScriptFunctions import com.mussonindustrial.ignition.embr.periscope.scripting.QueueFunctions import java.util.* @@ -34,6 +36,9 @@ class PeriscopeGatewayHook : AbstractGatewayModuleHook() { logger.debug("Registering components...") context.registerComponents() + + logger.debug("Starting project lifecycles...") + context.startupProjectLifecycles() } override fun shutdown() { @@ -45,6 +50,12 @@ class PeriscopeGatewayHook : AbstractGatewayModuleHook() { logger.debug("Removing components...") context.removeComponents() + + logger.debug("Removing servlets...") + context.removeServlets() + + logger.debug("Stopping project lifecycles...") + context.shutdownProjectLifecycles() } override fun getMountedResourceFolder(): Optional { @@ -80,4 +91,11 @@ class PeriscopeGatewayHook : AbstractGatewayModuleHook() { PropertiesFileDocProvider(), ) } + + override fun mountRouteHandlers(routes: RouteGroup) { + ClientResourceManifestHandler(context) + .mount(routes.newRoute("/client-resources/:project_name/manifest.json")) + ClientResourceJavaScriptHandler(context) + .mount(routes.newRoute("/client-resources/:project_name/js/*")) + } } diff --git a/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/handlers/ClientResourceJavaScriptHandler.kt b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/handlers/ClientResourceJavaScriptHandler.kt new file mode 100644 index 00000000..636070d7 --- /dev/null +++ b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/handlers/ClientResourceJavaScriptHandler.kt @@ -0,0 +1,106 @@ +package com.mussonindustrial.ignition.embr.periscope.handlers + +import com.inductiveautomation.ignition.common.project.resource.ResourcePath +import com.inductiveautomation.ignition.gateway.dataroutes.HttpMethod +import com.inductiveautomation.ignition.gateway.dataroutes.RequestContext +import com.inductiveautomation.ignition.gateway.dataroutes.RouteGroup +import com.inductiveautomation.ignition.gateway.dataroutes.RouteHandler +import com.inductiveautomation.perspective.gateway.api.SessionScope +import com.mussonindustrial.embr.common.logging.getLoggerEx +import com.mussonindustrial.ignition.embr.periscope.PeriscopeGatewayContext +import com.mussonindustrial.ignition.embr.periscope.resources.TypeScriptResource +import com.mussonindustrial.ignition.embr.periscope.utils.getHashKey +import java.net.URLDecoder +import java.util.EnumSet +import javax.servlet.http.HttpServletResponse + +class ClientResourceJavaScriptHandler(val context: PeriscopeGatewayContext) : RouteHandler { + + val logger = this.getLoggerEx() + val resourceNameRegex = Regex("""^(.*)\.([^.]+)\.js$""") + + fun mount(mounter: RouteGroup.RouteMounter) { + mounter + .method(HttpMethod.GET) + .type("text/javascript") + .restrict(context.requireSession(EnumSet.allOf(SessionScope::class.java))) + .handler(this) + .mount() + } + + override fun handle(request: RequestContext, response: HttpServletResponse) { + logger.trace("JS resource request: $request") + + val projectName = + request.getParameter("project_name") + ?: return response.sendError( + HttpServletResponse.SC_BAD_REQUEST, + "Missing project name", + ) + + val resourcePath = request.path.substringAfter("$projectName/js/") + val resourceName = URLDecoder.decode(resourcePath, Charsets.UTF_8) + logger.trace("Project: $projectName, Resource: $resourceName") + + val parsed = + parseResourceName(resourceName) + ?: return response.sendError( + HttpServletResponse.SC_NOT_FOUND, + "Invalid resource name: $resourceName", + ) + + val project = context.projectManager.getProject(projectName).orElse(null) + if (project == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "Project not found: $projectName") + return + } + + val resource = + project.getResource(ResourcePath(TypeScriptResource.type, parsed.pathName)).orElse(null) + if (resource == null) { + response.sendError( + HttpServletResponse.SC_NOT_FOUND, + "Resource not found: ${parsed.fullName}", + ) + return + } + + if (resource.getHashKey() != parsed.hash) { + response.sendError( + HttpServletResponse.SC_NOT_FOUND, + "Invalid resource hash: ${parsed.hash}", + ) + return + } + + val data = resource.getData(TypeScriptResource.CLIENT_KEY) + if (data == null) { + response.sendError( + HttpServletResponse.SC_NOT_FOUND, + "No data for resource: ${parsed.fullName}", + ) + return + } + + response.apply { + contentType = "text/javascript" + setHeader("Cache-Control", "public, max-age=604800, immutable") + outputStream.use { it.write(data) } + } + } + + private data class ParsedResourceName( + val fullName: String, + val pathName: String, + val hash: String, + ) + + private fun parseResourceName(resourceName: String): ParsedResourceName? { + val match = resourceNameRegex.find(resourceName) ?: return null + val (pathName, hash) = match.destructured + + logger.trace("resourceName: $resourceName, pathName: $pathName, hash: $hash") + + return ParsedResourceName(resourceName, pathName, hash) + } +} diff --git a/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/handlers/ClientResourceManifestHandler.kt b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/handlers/ClientResourceManifestHandler.kt new file mode 100644 index 00000000..d099b940 --- /dev/null +++ b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/handlers/ClientResourceManifestHandler.kt @@ -0,0 +1,68 @@ +package com.mussonindustrial.ignition.embr.periscope.handlers + +import com.inductiveautomation.ignition.common.gson.JsonArray +import com.inductiveautomation.ignition.common.gson.JsonObject +import com.inductiveautomation.ignition.gateway.dataroutes.HttpMethod +import com.inductiveautomation.ignition.gateway.dataroutes.RequestContext +import com.inductiveautomation.ignition.gateway.dataroutes.RouteGroup +import com.inductiveautomation.ignition.gateway.dataroutes.RouteHandler +import com.inductiveautomation.perspective.gateway.api.SessionScope +import com.mussonindustrial.embr.common.logging.getLoggerEx +import com.mussonindustrial.embr.gateway.api.sendSuccess +import com.mussonindustrial.ignition.embr.periscope.PeriscopeGatewayContext +import com.mussonindustrial.ignition.embr.periscope.resources.TypeScriptResource +import com.mussonindustrial.ignition.embr.periscope.utils.getHashKey +import java.util.EnumSet +import javax.servlet.http.HttpServletResponse + +class ClientResourceManifestHandler(val context: PeriscopeGatewayContext) : RouteHandler { + + val logger = this.getLoggerEx() + + fun mount(routeMounter: RouteGroup.RouteMounter) { + routeMounter + .method(HttpMethod.GET) + .type("application/json") + .restrict(context.requireSession(EnumSet.allOf(SessionScope::class.java))) + .handler(this) + .mount() + } + + override fun handle(request: RequestContext, response: HttpServletResponse) { + val projectName = request.getParameter("project_name") + logger.trace("Manifest request for project: $projectName") + + val project = context.projectManager.getProject(projectName).orElse(null) + if (project == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "Project not found: $projectName") + return + } + + val librariesArray = JsonArray() + + val resources = project.getResourcesOfType(TypeScriptResource.type) + logger.trace("Found ${resources.size} TypeScript resources") + + val jsArray = + JsonArray().apply { + resources.forEach { + add( + JsonObject().apply { + addProperty("path", it.resourcePath.path.toString()) + addProperty("hash", it.getHashKey()) + } + ) + } + } + + val json = + JsonObject().apply { + add("js", jsArray) + add("libraries", librariesArray) + } + + response.setHeader("Cache-Control", "no-store") + response.sendSuccess(json) + return + } +} diff --git a/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/resources/typescript/TypeScriptChangeListener.kt b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/resources/typescript/TypeScriptChangeListener.kt new file mode 100644 index 00000000..535c89a3 --- /dev/null +++ b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/resources/typescript/TypeScriptChangeListener.kt @@ -0,0 +1,53 @@ +package com.mussonindustrial.ignition.embr.periscope.resources.typescript + +import com.inductiveautomation.ignition.common.gson.JsonObject +import com.inductiveautomation.ignition.common.project.RuntimeProject +import com.inductiveautomation.ignition.common.project.resource.ProjectResource +import com.inductiveautomation.ignition.common.project.resource.ProjectResourceId +import com.inductiveautomation.ignition.gateway.project.ProjectLifecycle +import com.inductiveautomation.ignition.gateway.project.ProjectLifecycleFactory +import com.inductiveautomation.ignition.gateway.project.ProjectManager +import com.inductiveautomation.ignition.gateway.project.ResourceFilter +import com.inductiveautomation.perspective.gateway.api.PerspectiveContext +import com.mussonindustrial.ignition.embr.periscope.resources.TypeScriptResource + +class TypeScriptChangeListener(val context: PerspectiveContext, projectManager: ProjectManager) : + ProjectLifecycleFactory(projectManager) { + val filter: ResourceFilter = + ResourceFilter.newBuilder().addResourceType(TypeScriptResource.Companion.type).build() + + override fun createProjectLifecycle(project: RuntimeProject): Notifier { + return Notifier(project) + } + + override fun getResourceFilter(): ResourceFilter { + return filter + } + + inner class Notifier(project: RuntimeProject) : ProjectLifecycle(project) { + + fun notifyClients() { + context.sessionMonitor.getClientSessionsForProject(project.name).forEach { + it.pages.forEach { page -> + page.send("periscope-client-resource-refresh", JsonObject()) + } + } + } + + override fun onStartup(resourceIds: List) {} + + override fun onShutdown(resourceIds: List) {} + + override fun onResourcesCreated(resources: List) { + notifyClients() + } + + override fun onResourcesModified(resources: List) { + notifyClients() + } + + override fun onResourcesDeleted(resources: List) { + notifyClients() + } + } +} diff --git a/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/utils/ProjectResourceUtils.kt b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/utils/ProjectResourceUtils.kt new file mode 100644 index 00000000..9c41f9e5 --- /dev/null +++ b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/utils/ProjectResourceUtils.kt @@ -0,0 +1,8 @@ +package com.mussonindustrial.ignition.embr.periscope.utils + +import com.inductiveautomation.ignition.common.project.resource.ProjectResource +import org.apache.commons.codec.binary.Hex + +fun ProjectResource.getHashKey(): String { + return Hex.encodeHexString(resourceSignature.signature).substring(0, 20) +} diff --git a/modules/periscope/gateway/src/main/resources/localization.properties b/modules/periscope/gateway/src/main/resources/localization.properties index a2d98d6e..0c8eae90 100644 --- a/modules/periscope/gateway/src/main/resources/localization.properties +++ b/modules/periscope/gateway/src/main/resources/localization.properties @@ -15,3 +15,12 @@ script.invokeOnQueue.desc=Run a function on a Perspective execution queue, with script.invokeOnQueue.param.function=Python function to run. Should be a function reference that takes a single "scope" parameter. script.invokeOnQueue.param.delay=Optional. Time in milliseconds before running the function. script.invokeOnQueue.param.scope=Optional. Lifecycle scope. If specified, must be "session", "page", or "view", otherwise the default will be "view". + +javascript-module.noun=JavaScript Module +javascript-module.nouns=JavaScript Modules +javascript-module.noun-long=JavaScript Module +javascript-module.nouns-long=JavaScript Modules +javascript-module.error.notFound=JavaScript Module not found. +javascript-module.error.existingPalette=JavaScript Module '%s' is already defined +javascript-module.error.emptyPalette=JavaScript Module name must not be empty +javascript-module.action.new=New JavaScript Module diff --git a/modules/periscope/ts-compiler/build.gradle.kts b/modules/periscope/ts-compiler/build.gradle.kts new file mode 100644 index 00000000..182f26db --- /dev/null +++ b/modules/periscope/ts-compiler/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("embr.build.ignition-webjar") +} \ No newline at end of file diff --git a/modules/periscope/ts-compiler/package.json b/modules/periscope/ts-compiler/package.json new file mode 100644 index 00000000..849784b3 --- /dev/null +++ b/modules/periscope/ts-compiler/package.json @@ -0,0 +1,17 @@ +{ + "name": "@embr-modules/periscope-ts-compiler", + "private": true, + "version": "0.9.0", + "scripts": { + "dev": "vite", + "build": "tsc && nx vite:build && nx vite:test --run", + "clean": "s", + "test": "nx vite:test --run", + "preview": "nx vite:test --preview", + "lint": "eslint src/**/*.ts", + "lint:prettier": "prettier -c . --cache --ignore-path=../../.prettierignore" + }, + "dependencies": { + "@embr-js/utils": "0.6.1" + } +} diff --git a/modules/periscope/ts-compiler/src/importTransformer.ts b/modules/periscope/ts-compiler/src/importTransformer.ts new file mode 100644 index 00000000..d6361db5 --- /dev/null +++ b/modules/periscope/ts-compiler/src/importTransformer.ts @@ -0,0 +1,256 @@ +import ts from 'typescript' + +/** + * Resolve relative module paths. + */ +function resolveModulePath(base: string, spec: string): string { + if (!spec.startsWith('.')) return spec + + const baseParts = base.split('/') + baseParts.pop() + + for (const part of spec.split('/')) { + if (part === '.' || part === '') continue + if (part === '..') baseParts.pop() + else baseParts.push(part) + } + + return baseParts.join('/').replace(/\/+/g, '/') +} + +/** + * Runtime globals live under: + * window.__embrGlobals.scripting.globals. + */ +function isGlobalImport(spec: string) { + return !spec.startsWith('.') && !spec.includes('/') +} + +function createGlobalAccess(name: string): ts.Expression { + return ts.factory.createPropertyAccessExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('window'), + '__embrGlobals' + ), + 'modules' + ), + name + ) +} + +type ImportRecord = { + moduleName: string + clause: ts.ImportClause | null + isGlobal: boolean +} + +export function createImportTransformer( + modulePath: string +): ts.TransformerFactory { + return (context) => { + return (sourceFile) => { + const imports: ImportRecord[] = [] + + const visitor = (node: ts.Node): ts.Node => { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) + ) { + const spec = node.moduleSpecifier.text + const global = isGlobalImport(spec) + + imports.push({ + moduleName: global ? spec : resolveModulePath(modulePath, spec), + clause: node.importClause ?? null, + isGlobal: global, + }) + + return ts.factory.createEmptyStatement() + } + + return ts.visitEachChild(node, visitor, context) + } + + const newSourceFile = ts.visitNode(sourceFile, visitor) as ts.SourceFile + + if (imports.length === 0) return newSourceFile + + const moduleImports = imports.filter((i) => !i.isGlobal) + const statements: ts.Statement[] = [] + + let modulesIdentifier: ts.Identifier | null = null + + /* + * Generate Promise.all() for module imports + */ + if (moduleImports.length > 0) { + modulesIdentifier = ts.factory.createIdentifier('__modules') + + const calls = moduleImports.map((imp) => + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('window'), + '__embrGlobals' + ), + 'getClientResource' + ), + undefined, + [ts.factory.createStringLiteral(imp.moduleName)] + ) + ) + + statements.push( + ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + modulesIdentifier, + undefined, + undefined, + ts.factory.createAwaitExpression( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('Promise'), + 'all' + ), + undefined, + [ts.factory.createArrayLiteralExpression(calls)] + ) + ) + ), + ], + ts.NodeFlags.Const + ) + ) + ) + } + + /* + * Generate bindings + */ + let moduleIndex = 0 + + for (const imp of imports) { + const clause = imp.clause + if (!clause) continue + + const sourceExpr = imp.isGlobal + ? createGlobalAccess(imp.moduleName) + : ts.factory.createElementAccessExpression( + modulesIdentifier!, + ts.factory.createNumericLiteral(moduleIndex++) + ) + + /* + * Namespace import + * + * import * as foo from 'bar' + */ + if ( + clause.namedBindings && + ts.isNamespaceImport(clause.namedBindings) + ) { + statements.push( + ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + clause.namedBindings.name, + undefined, + undefined, + sourceExpr + ), + ], + ts.NodeFlags.Const + ) + ) + ) + continue + } + + /* + * Default-only global import + * + * import foo from 'lodash' + * -> const foo = window.__embrGlobals.modules.lodash + */ + if ( + imp.isGlobal && + clause.name && + !(clause.namedBindings && ts.isNamedImports(clause.namedBindings)) + ) { + statements.push( + ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + clause.name, + undefined, + undefined, + sourceExpr + ), + ], + ts.NodeFlags.Const + ) + ) + ) + continue + } + + /* + * Named / default imports (normal destructuring) + */ + const elements: ts.BindingElement[] = [] + + if (clause.name) { + elements.push( + ts.factory.createBindingElement( + undefined, + ts.factory.createIdentifier('default'), + clause.name + ) + ) + } + + if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const spec of clause.namedBindings.elements) { + elements.push( + ts.factory.createBindingElement( + undefined, + spec.propertyName, + spec.name + ) + ) + } + } + + statements.push( + ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + ts.factory.createObjectBindingPattern(elements), + undefined, + undefined, + sourceExpr + ), + ], + ts.NodeFlags.Const + ) + ) + ) + } + + return ts.factory.updateSourceFile(newSourceFile, [ + ...statements, + ...newSourceFile.statements.filter((s) => !ts.isEmptyStatement(s)), + ]) + } + } +} diff --git a/modules/periscope/ts-compiler/src/index.ts b/modules/periscope/ts-compiler/src/index.ts new file mode 100644 index 00000000..e8153268 --- /dev/null +++ b/modules/periscope/ts-compiler/src/index.ts @@ -0,0 +1,34 @@ +import * as prettier from 'prettier/standalone' +import * as parserTypeScript from 'prettier/parser-typescript' +import * as prettierPluginEstree from 'prettier/plugins/estree' + +import ts from 'typescript' +import { createImportTransformer } from './importTransformer' + +export function compile(input: string, modulePath: string) { + return ts.transpileModule(input, { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2020, + strict: true, + sourceMap: false, + jsx: ts.JsxEmit.React, + }, + transformers: { + before: [createImportTransformer(modulePath)], + }, + }) +} + +export async function format(source: string, cursorOffset: number) { + return await prettier.formatWithCursor(source, { + semi: false, + singleQuote: true, + useTabs: false, + endOfLine: 'lf', + cursorOffset, + parser: 'typescript', + printWidth: 120, + plugins: [prettierPluginEstree as unknown as string, parserTypeScript], + }) +} diff --git a/modules/periscope/ts-compiler/tsconfig.json b/modules/periscope/ts-compiler/tsconfig.json new file mode 100644 index 00000000..471a66e8 --- /dev/null +++ b/modules/periscope/ts-compiler/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@embr-js/tsconfig/vite.json", + "include": ["src"] +} diff --git a/modules/periscope/ts-compiler/vite.config.ts b/modules/periscope/ts-compiler/vite.config.ts new file mode 100644 index 00000000..91aac42b --- /dev/null +++ b/modules/periscope/ts-compiler/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import { resolve } from 'path' + +const packageName = 'embr-periscope-ts-compiler' + +export default defineConfig(({ mode }) => ({ + build: { + outDir: './dist', + lib: { + entry: resolve(__dirname, 'src/index.ts'), + fileName: () => { + return `${packageName}.js` + }, + cssFileName: `${packageName}`, + name: 'EmbrPeriscopeTsCompiler', + formats: ['umd'], + }, + }, + test: { + passWithNoTests: true, + }, + define: { + 'process.env.NODE_ENV': JSON.stringify(mode), + }, +})) diff --git a/modules/periscope/web/src/client.ts b/modules/periscope/web/src/client.ts index 10f60e92..b6a6201a 100644 --- a/modules/periscope/web/src/client.ts +++ b/modules/periscope/web/src/client.ts @@ -10,6 +10,8 @@ import { JsonViewComponentMeta, PortalComponent, PortalComponentMeta, + ReactComponent, + ReactComponentMeta, } from './components' import { installExtensions } from './extensions' import { waitForClientStore } from '@embr-js/perspective-client' @@ -20,6 +22,7 @@ export { EmbeddedViewComponent, JsonViewComponent, PortalComponent, + ReactComponent, } const components = [ @@ -28,8 +31,9 @@ const components = [ new EmbeddedViewComponentMeta(), new JsonViewComponentMeta(), new PortalComponentMeta(), + new ReactComponentMeta(), ] components.forEach((c) => ComponentRegistry.register(c)) -waitForClientStore((clientStore) => installExtensions(clientStore)) +waitForClientStore(async (clientStore) => await installExtensions(clientStore)) diff --git a/modules/periscope/web/src/components/embedding/index.ts b/modules/periscope/web/src/components/embedding/index.ts index 4ef72e11..655b2fee 100644 --- a/modules/periscope/web/src/components/embedding/index.ts +++ b/modules/periscope/web/src/components/embedding/index.ts @@ -1,5 +1,6 @@ export * from './flex-repeater' export * from './json-view' export * from './portal' +export * from './react' export * from './swiper' export * from './view' diff --git a/modules/periscope/web/src/components/embedding/react.tsx b/modules/periscope/web/src/components/embedding/react.tsx new file mode 100644 index 00000000..478c0556 --- /dev/null +++ b/modules/periscope/web/src/components/embedding/react.tsx @@ -0,0 +1,145 @@ +import React, { + createElement, + lazy, + MutableRefObject, + Suspense, + useEffect, + useRef, +} from 'react' +import { + AbstractUIElementStore, + ComponentMeta, + ComponentProps, + ComponentStoreDelegate, + JsObject, + PComponent, + PlainObject, + PropertyTree, + SizeObject, + StyleObject, +} from '@inductiveautomation/perspective-client' +import { + ComponentDelegateJavaScriptProxy, + ComponentEvents, + ComponentLifecycleEvents, + getScriptTransform, + JavaScriptRunEvent, + useDeepCompareMemo, +} from '@embr-js/perspective-client' +import { transformProps } from '@embr-js/utils' +import { getClientResource } from '../../extensions' + +const COMPONENT_TYPE = 'embr.periscope.embedding.react' + +type ReactComponentProps = Record + +type ReactProps = { + component: string + props: ReactComponentProps + events: ComponentEvents & { + target: { + lifecycle: ComponentLifecycleEvents + } + } + style: StyleObject +} + +export function ReactComponent(props: ComponentProps) { + const ref: MutableRefObject = useRef(undefined) + + // Register the chart with the component delegate + useEffect(() => { + const delegate = props.store.delegate as ReactComponentDelegate + delegate.setProxyRef(ref.current as never) + }, [props.store.delegate, ref.current]) + + const component = useDeepCompareMemo(() => { + if (props.props.component == '') { + return null + } + + return lazy(() => + getClientResource( + props.store.view.page.parent, + props.props.component + ).then((resource) => { + if (resource == null) { + throw Error('Component Failed to Load') + } + return resource + }) + ) + }, [props.props.component]) + + if (component == null) { + return null + } + + // Apply transforms to the user supplied properties + const innerProps = useDeepCompareMemo(() => { + const transformedProps = transformProps(props.props.props, [ + getScriptTransform(props, props.store), + ]) as ReactComponentProps + + return { + ...transformedProps, + ref, + } as unknown + }, [props.props.props, ref]) as ReactComponentProps + + return ( +
+
}> + {createElement(component, innerProps)} + + + ) +} + +export class ReactComponentDelegate extends ComponentStoreDelegate { + private proxy = new ComponentDelegateJavaScriptProxy(this) + + setProxyRef(ref?: object) { + this.proxy.setRef(ref) + } + + handleEvent(eventName: string, eventObject: JsObject) { + if (this.proxy.handles(eventName)) { + this.proxy.handleEvent(eventObject as JavaScriptRunEvent) + } + } + + mapStateToProps(): PlainObject { + return this + } +} + +export class ReactComponentMeta implements ComponentMeta { + getComponentType(): string { + return COMPONENT_TYPE + } + + getDefaultSize(): SizeObject { + return { + width: 300, + height: 300, + } + } + + createDelegate(component: AbstractUIElementStore): ComponentStoreDelegate { + return new ReactComponentDelegate(component) + } + + getPropsReducer(tree: PropertyTree): ReactProps { + return { + component: tree.readString('component', ''), + props: tree.readObject('props', {}), + events: tree.readObject('events', {}), + style: tree.readStyle('style'), + } as never + } + + getViewComponent(): PComponent { + return ReactComponent as PComponent + } +} diff --git a/modules/periscope/web/src/extensions/clientResource.ts b/modules/periscope/web/src/extensions/clientResource.ts new file mode 100644 index 00000000..63178032 --- /dev/null +++ b/modules/periscope/web/src/extensions/clientResource.ts @@ -0,0 +1,137 @@ +import Lodash from 'lodash' +import PerspectiveClient from '@inductiveautomation/perspective-client' +import PerspectiveComponents from '@inductiveautomation/perspective-components' +import React from 'react' + +import { ClientStore } from '@inductiveautomation/perspective-client' +import { merge } from 'lodash' +import { getEmbrGlobals } from '@embr-js/perspective-client' + +const PROTOCOL = { + REFRESH: 'periscope-client-resource-refresh', +} + +export type JsResource = { + path: string + hash: string +} + +export type ResourceManifest = { + js: JsResource[] +} + +export type ResourceManifestResponse = { + data: ResourceManifest +} + +let loadedResourceManifest: ResourceManifest | undefined +let manifestPromise: Promise | undefined +const modules = new Map() + +function projectPaths(projectName: string) { + const base = '/data/embr-periscope/client-resources' + return { + base, + jsBase: `${base}/${projectName}/js`, + manifest: `${base}/${projectName}/manifest.json`, + } +} + +export async function getResourceManifest(clientStore: ClientStore) { + if (loadedResourceManifest) { + return loadedResourceManifest + } + + if (manifestPromise) return manifestPromise + + const { projectName } = clientStore + const paths = projectPaths(projectName) + + manifestPromise = (async () => { + const response = await fetch(paths.manifest) + if (!response.ok) + throw new Error(`Failed to fetch manifest: ${response.statusText}`) + + const manifestResponseJson = + (await response.json()) as ResourceManifestResponse + + loadedResourceManifest = manifestResponseJson.data + manifestPromise = undefined + return loadedResourceManifest + })() + + return manifestPromise +} + +export async function getClientResource( + clientStore: ClientStore, + path: string +) { + if (modules.has(path)) { + return modules.get(path) + } + + const { projectName } = clientStore + const { jsBase } = projectPaths(projectName) + + const manifest = await getResourceManifest(clientStore) + const hash = manifest.js.find((resource) => resource.path == path)?.hash + if (!hash) return null + + try { + const module = await import(`${jsBase}/${encodeURI(path)}.${hash}.js`) + modules.set(path, module) + return module + } catch (err) { + console.error(`Failed to load ClientResource '${path}':`, err) + return null + } +} + +export async function refreshClientResources(clientStore: ClientStore) { + await getResourceManifest(clientStore).then( + async (manifest) => + await Promise.all( + manifest.js.map((js) => getClientResource(clientStore, js.path)) + ) + ) +} + +export function installClientResourceGlobals(clientStore: ClientStore) { + const throwError = (message: string) => { + throw new Error(message) + } + + merge(getEmbrGlobals().scripting.globals, { + periscope: { + module: (path: string) => + modules.get(path) ?? throwError(`Module ${path} not found.`), + }, + }) + + merge(getEmbrGlobals(), { + getClientResource: async (path: string) => + await getClientResource(clientStore, path), + }) + + merge(getEmbrGlobals().modules, { + lodash: Lodash, + '@inductiveautomation/perspective-client': PerspectiveClient, + '@inductiveautomation/perspective-components': PerspectiveComponents, + react: React, + periscope: getEmbrGlobals().scripting.globals.periscope, + perspective: getEmbrGlobals().scripting.globals.perspective, + }) +} + +export function installClientResourceListener(clientStore: ClientStore) { + clientStore.connection.handlers.set(PROTOCOL.REFRESH, async () => { + window.location.reload() + }) +} + +export async function installClientResources(clientStore: ClientStore) { + installClientResourceGlobals(clientStore) + installClientResourceListener(clientStore) + await refreshClientResources(clientStore) +} diff --git a/modules/periscope/web/src/extensions/index.ts b/modules/periscope/web/src/extensions/index.ts index 0eefb679..966c28d7 100644 --- a/modules/periscope/web/src/extensions/index.ts +++ b/modules/periscope/web/src/extensions/index.ts @@ -1,10 +1,14 @@ import { ClientStore } from '@inductiveautomation/perspective-client' import { installRunJavaScript } from './runJavaScript' import { installToasts } from './toast' +import { installClientResources } from './clientResource' +export * from './clientResource' export * from './runJavaScript' +export * from './toast' -export function installExtensions(clientStore: ClientStore) { +export async function installExtensions(clientStore: ClientStore) { installRunJavaScript(clientStore) installToasts(clientStore) + await installClientResources(clientStore) } diff --git a/settings.gradle.kts b/settings.gradle.kts index af3c29fa..a2147cca 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,6 +43,7 @@ include( ":modules:periscope:common", ":modules:periscope:designer", ":modules:periscope:gateway", + ":modules:periscope:ts-compiler", ":modules:periscope:web", ":modules:thermo:common", diff --git a/yarn.lock b/yarn.lock index eeb53843..4088c9a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1116,131 +1116,261 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz#a1414903bb38027382f85f03dda6065056757727" integrity sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA== +"@esbuild/aix-ppc64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" + integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== + "@esbuild/android-arm64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz#c859994089e9767224269884061f89dae6fb51c6" integrity sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w== +"@esbuild/android-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" + integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== + "@esbuild/android-arm@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.8.tgz#96a8f2ca91c6cd29ea90b1af79d83761c8ba0059" integrity sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw== +"@esbuild/android-arm@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" + integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== + "@esbuild/android-x64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.8.tgz#a3a626c4fec4a024a9fa8c7679c39996e92916f0" integrity sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA== +"@esbuild/android-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" + integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== + "@esbuild/darwin-arm64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz#a5e1252ca2983d566af1c0ea39aded65736fc66d" integrity sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw== +"@esbuild/darwin-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" + integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== + "@esbuild/darwin-x64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz#5271b0df2bb12ce8df886704bfdd1c7cc01385d2" integrity sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg== +"@esbuild/darwin-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" + integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== + "@esbuild/freebsd-arm64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz#d0a0e7fdf19733b8bb1566b81df1aa0bb7e46ada" integrity sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA== +"@esbuild/freebsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" + integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== + "@esbuild/freebsd-x64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz#2de8b2e0899d08f1cb1ef3128e159616e7e85343" integrity sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw== +"@esbuild/freebsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" + integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== + "@esbuild/linux-arm64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz#a4209efadc0c2975716458484a4e90c237c48ae9" integrity sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w== +"@esbuild/linux-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" + integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== + "@esbuild/linux-arm@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz#ccd9e291c24cd8d9142d819d463e2e7200d25b19" integrity sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg== +"@esbuild/linux-arm@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" + integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== + "@esbuild/linux-ia32@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz#006ad1536d0c2b28fb3a1cf0b53bcb85aaf92c4d" integrity sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg== +"@esbuild/linux-ia32@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" + integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== + "@esbuild/linux-loong64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz#127b3fbfb2c2e08b1397e985932f718f09a8f5c4" integrity sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ== +"@esbuild/linux-loong64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" + integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== + "@esbuild/linux-mips64el@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz#837d1449517791e3fa7d82675a2d06d9f56cb340" integrity sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw== +"@esbuild/linux-mips64el@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" + integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== + "@esbuild/linux-ppc64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz#aa2e3bd93ab8df084212f1895ca4b03c42d9e0fe" integrity sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ== +"@esbuild/linux-ppc64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" + integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== + "@esbuild/linux-riscv64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz#a340620e31093fef72767dd28ab04214b3442083" integrity sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg== +"@esbuild/linux-riscv64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" + integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== + "@esbuild/linux-s390x@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz#ddfed266c8c13f5efb3105a0cd47f6dcd0e79e71" integrity sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg== +"@esbuild/linux-s390x@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" + integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== + "@esbuild/linux-x64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz#9a4f78c75c051e8c060183ebb39a269ba936a2ac" integrity sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ== +"@esbuild/linux-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" + integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== + "@esbuild/netbsd-arm64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz#902c80e1d678047926387230bc037e63e00697d0" integrity sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw== +"@esbuild/netbsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" + integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== + "@esbuild/netbsd-x64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz#2d9eb4692add2681ff05a14ce99de54fbed7079c" integrity sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg== +"@esbuild/netbsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" + integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== + "@esbuild/openbsd-arm64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz#89c3b998c6de739db38ab7fb71a8a76b3fa84a45" integrity sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ== +"@esbuild/openbsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" + integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== + "@esbuild/openbsd-x64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz#2f01615cf472b0e48c077045cfd96b5c149365cc" integrity sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ== +"@esbuild/openbsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" + integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== + "@esbuild/openharmony-arm64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz#a201f720cd2c3ebf9a6033fcc3feb069a54b509a" integrity sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg== +"@esbuild/openharmony-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" + integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== + "@esbuild/sunos-x64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz#07046c977985a3334667f19e6ab3a01a80862afb" integrity sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w== +"@esbuild/sunos-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" + integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== + "@esbuild/win32-arm64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz#4a5470caf0d16127c05d4833d4934213c69392d1" integrity sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ== +"@esbuild/win32-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" + integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== + "@esbuild/win32-ia32@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz#3de3e8470b7b328d99dbc3e9ec1eace207e5bbc4" integrity sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg== +"@esbuild/win32-ia32@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" + integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== + "@esbuild/win32-x64@0.25.8": version "0.25.8" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz#610d7ea539d2fcdbe39237b5cc175eb2c4451f9c" integrity sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw== +"@esbuild/win32-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" + integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== + "@eslint-community/eslint-utils@^4.2.0": version "4.4.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz#d1145bf2c20132d6400495d6df4bf59362fd9d56" @@ -4508,6 +4638,38 @@ es6-promisify@^5.0.0: dependencies: es6-promise "^4.0.3" +esbuild@0.27.3: + version "0.27.3" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8" + integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.27.3" + "@esbuild/android-arm" "0.27.3" + "@esbuild/android-arm64" "0.27.3" + "@esbuild/android-x64" "0.27.3" + "@esbuild/darwin-arm64" "0.27.3" + "@esbuild/darwin-x64" "0.27.3" + "@esbuild/freebsd-arm64" "0.27.3" + "@esbuild/freebsd-x64" "0.27.3" + "@esbuild/linux-arm" "0.27.3" + "@esbuild/linux-arm64" "0.27.3" + "@esbuild/linux-ia32" "0.27.3" + "@esbuild/linux-loong64" "0.27.3" + "@esbuild/linux-mips64el" "0.27.3" + "@esbuild/linux-ppc64" "0.27.3" + "@esbuild/linux-riscv64" "0.27.3" + "@esbuild/linux-s390x" "0.27.3" + "@esbuild/linux-x64" "0.27.3" + "@esbuild/netbsd-arm64" "0.27.3" + "@esbuild/netbsd-x64" "0.27.3" + "@esbuild/openbsd-arm64" "0.27.3" + "@esbuild/openbsd-x64" "0.27.3" + "@esbuild/openharmony-arm64" "0.27.3" + "@esbuild/sunos-x64" "0.27.3" + "@esbuild/win32-arm64" "0.27.3" + "@esbuild/win32-ia32" "0.27.3" + "@esbuild/win32-x64" "0.27.3" + esbuild@^0.25.0: version "0.25.8" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.8.tgz#482d42198b427c9c2f3a81b63d7663aecb1dda07"