Search code examples
javascriptkotlinkotlin-jskotlin-js-interop

How to convert Javascript exported class to Kotlin/JS?


I am new to JS and to Kotlin/JS. I have the following minimal working Javascript code for a Plugin for Obsidian from an example. It works as expected:

var obsidian = require('obsidian');
class SomePlugin extends obsidian.Plugin {
    onload() {
        new obsidian.Notice('This is a notice!');
    }
}
module.exports = Plugin;

I was hoping to extend this plugin using Kotlin as I know the language, but I have some problems converting this to Kotlin/JS. My approach so far:

The runnable project can be found here on Github. Run gradle build to generate the build folder. It will fail in the browser step, but that step is not necessary. After the build the generated js file can be found in build\js\packages\main\kotlin\main.js.

main.kt

@JsExport
class SomePlugin: Plugin() {
    override fun onload() {
        Notice("This is a notice!")
    }
}
@JsModule("obsidian")
@JsNonModule // required by the umd moduletype
external open class Component {
    open fun onload()
}
@JsModule("obsidian")
@JsNonModule
external open class Plugin : Component {
}
@JsModule("obsidian")
@JsNonModule
external open class Notice(message: String, timeout: Number = definedExternally) {
    open fun hide()
}

Edit: Thanks to the comment of @S.Janssen I switched the module type to umd

build.gradle.kts

plugins {
    kotlin("js") version "1.5.20"
}
group = "de.example"
version = "1.0-SNAPSHOT"
repositories {
    mavenCentral()
}
dependencies {
    implementation(npm("obsidian", "0.12.5", false))
}
kotlin {
    js(IR) {
        binaries.executable()
        browser {
            webpackTask {
                output.libraryTarget = "umd"
            }
        }
    }
}

tasks.withType<KotlinJsCompile>().configureEach {
    kotlinOptions.moduleKind = "umd"
}

I don't actually need a result that can be run in the browser, but without the browser definition, it would not even generate a js file. With the browser part, an exception is thrown saying Can't resolve 'obsidian' in 'path\kotlin'. But at least a .js file is created under build/js/packages/test/kotlin/test.js. However the code is completely different from my expected code and also is not accepted by obsidian as a valid plugin code. I also tried some other gradle options. like "umd", "amd", "plain", legacy compiler instead of IR, nodejs instead of browser. But nothing creates a runnable js file. The error messages differ. With the legacy compiler it requires the kotlin.js file, that it cannot find even if I put it right next to it in the folder or copy the content into the script.

How do I get code functionally similar to the Javascript code posted above? I understand that it will have overhead, but the code currently generated does not even define or export my class by my understanding.

The error message that I get from obisidan debugger:

Plugin failure: obsidian-sample-plugin TypeError: Object prototype may only be an Object or null: undefined

The code generated:

    (function (root, factory) {
  if (typeof define === 'function' && define.amd)
    define(['exports', 'obsidian', 'obsidian', 'obsidian'], factory);
  else if (typeof exports === 'object')
    factory(module.exports, require('obsidian'), require('obsidian'), require('obsidian'));
  else {
    if (typeof Component === 'undefined') {
      throw new Error("Error loading module 'main'. Its dependency 'obsidian' was not found. Please, check whether 'obsidian' is loaded prior to 'main'.");
    }if (typeof Plugin === 'undefined') {
      throw new Error("Error loading module 'main'. Its dependency 'obsidian' was not found. Please, check whether 'obsidian' is loaded prior to 'main'.");
    }if (typeof Notice === 'undefined') {
      throw new Error("Error loading module 'main'. Its dependency 'obsidian' was not found. Please, check whether 'obsidian' is loaded prior to 'main'.");
    }root.main = factory(typeof main === 'undefined' ? {} : main, Component, Plugin, Notice);
  }
}(this, function (_, Component, Plugin, Notice) {
  'use strict';
  SomePlugin.prototype = Object.create(Plugin.prototype);
  SomePlugin.prototype.constructor = SomePlugin;
  function Unit() {
    Unit_instance = this;
  }
  Unit.$metadata$ = {
    simpleName: 'Unit',
    kind: 'object',
    interfaces: []
  };
  var Unit_instance;
  function Unit_getInstance() {
    if (Unit_instance == null)
      new Unit();
    return Unit_instance;
  }
  function SomePlugin() {
    Plugin.call(this);
  }
  SomePlugin.prototype.onload_sv8swh_k$ = function () {
    new Notice('This is a notice!');
    Unit_getInstance();
  };
  SomePlugin.prototype.onload = function () {
    return this.onload_sv8swh_k$();
  };
  SomePlugin.$metadata$ = {
    simpleName: 'SomePlugin',
    kind: 'class',
    interfaces: []
  };
  _.SomePlugin = SomePlugin;
  return _;
}));

Solution

  • You can find a working example of what you're going for here. I'll go through some of the changes that needed to be made to your code one-by-one in this reply.

    Being unable to resolve obsidian

    Can't resolve 'obsidian' in 'path\kotlin' occurs because the obsidian-api package is not a standalone library. Instead, it only consist of a obsidian.d.ts file, which is a TypeScript declaration file. Similar to a header file in other languages, this header file does not provide any implementations, but only the signatures and types for the library – meaning Kotlin/JS' webpack (or any JavaScript tooling, for that matter) won't be able to resolve the actual implementations. This is expected, and can be addressed by declaring the module as external. To do so in Kotlin/JS, create a directory called webpack.config.d, and add a file 01.externals.js with the following content:

    config.externals = {
        obsidian: 'obsidian',
    };
    

    (You can actually find an equivalent snippet in the offical sample-plugin configuration, as well, since this isn't a Kotlin/JS specific problem)

    Grouping multiple @JsModule declarations

    Because you're importing multiple declarations from the same package, instead of annotating multiple signatures with @JsModule / @JsNonModule, you'll have to create a separate file, and annotate it with @file:@JsModule("...") / @file:JsNonModule:

    @file:JsModule("obsidian")
    @file:JsNonModule
    
    open external class Component {
        open fun onload()
        open fun onunload()
    }
    
    open external class Plugin(
        app: Any,
        manifest: Any
    ) : Component
    
    open external class Notice(message: String, timeout: Number = definedExternally) {
        open fun hide()
    }
    

    Kotlin's ES5 vs Obsidian's ES6

    Additionally, some of your problems stem from the fact that Obsidian's examples implicitly make the assumption that you are targeting ES6 (while Kotlin's current target is ES5). Specifically, this makes a difference in regards to how your plugin exports its members, as well as how classes are instantiated.

    Inheritance

    In regards to inheritance (since YourPlugin inherits from Plugin), ES6 classes automatically initialize the parent class with all arguments. This is something that is not supported in ES5's prototype inheritance. This is why in the snippet above, we need to explicitly pass the Plugin class constructor the app and manifest parameters, and pass them through in the implementation of your specific plugin:

    class SomePlugin(
        app: Any,
        manifest: Any
    ) : Plugin(
        app,
        manifest
    )
    

    Exports / Module System

    In regards to exporting your plugin, Obsidian expects either module.exports or exports.default to be your Plugin class directly. To achieve this exact export behavior, a few conditions need to be met, which is unfortunately a bit cumbersome: - The library target needs to be CommonJS: output.libraryTarget = "commonjs" (not CommonJS2) - To prevent creating a level of indirection, as is usually the case, the exported library need to be set to null: output.library = null - To export your Plugin under as default, its class declaration needs to be marked as @JsName("default").