当サイトの記事投稿作業はここにあるとおり、独自のvscode拡張機能を作成して行なっています。ところがいつのまにか記事をアップ出来ない問題が発生していることに気づきました。

既存のブログエディタでは通信部分が全て機能しないので、思い切って作り直すことにしました。
どうせ作り直すならTypeScriptではなくFableでやってみようと思ったのですが、ググってヒットするのはほとんどがFable2なんですよね...

Fableでのvscode extension プロジェクトテンプレート

Fableは現在Fable3が最新となっており、Fableコンパイラもdotnetのツールとして実装されています1

Fable3を使ったプロジェクトテンプレートをなかなか見つけることが出来なかったので、独自で進めていたのですがsource mapをうまく処理できず行き詰まりました...
思い切ってissueをあげて相談してみたところ、Alfonso Garcia-Caro 御大2自らプロジェクトテンプレートを作ってくれました...^^; 言ってみるもんだ...

package.json

通常、vscode extensionを作成する場合、公式のYour First Extensionにあるようにyeomanを使ってプロジェクトのひな形を作成します。

プロジェクトを作成するとルートディレクトリにpackage.jsonというファイルが作成されます。
このpackage.jsonに拡張機能の名称だとかコマンドなどを記述します。
御大が作成してくださったプロジェクトテンプレートにももちろんpackage.jsonが有ります。

特徴的なのはscriptsとdependencyでしょう。


  "scripts": {
    "install": "dotnet tool restore",
    "build": "dotnet fable src -o build --run npm run esbuild",
    "start": "dotnet fable watch src -s -o build --run npm run esbuild -- --watch",
    "esbuild": "esbuild ./build/Main.js --bundle --outfile=dist/main.js --external:vscode --format=cjs --platform=node --sourcemap",
    "prepack": "npm run build",
    "pack": "vsce package"
  },
  "dependencies": {
    "ionide-vscode-helpers": "ionide/ionide-vscode-helpers"
  },
  "devDependencies": {
    "esbuild": "^0.13.8"
  }

scriptsにあるinstallコマンドはdotnet tool restoreとなっています。
これはdotnetコマンドで実行するツールをインストールするコマンドで、インストールされるツールは.config\dotnet-tools.jsonに記載されています。

{
  "version": 1,
  "isRoot": true,
  "tools": {
    "fable": {
      "version": "3.4.4",
      "commands": [
        "fable"
      ]
    }
  }
}

fableコンパイラがインストールされるようになっていますね。
fableは活発に更新されているので、dotnet tool update fableをターミナルから実行して最新版にしておいた方が良いでしょう。ちなみに本記事執筆時点の最新版は3.6.3です。

scriptsに登録されているコマンドをざっと解説すると、

  • install ... dotnet toolのインストール
  • build ... fableでコンパイルしてesbuildでバンドル
  • start ... 上記と同様だがwatchモード(ソースに変更が有った場合に自動ビルド)でコンパイル
  • esbuild ... build/startコマンドから呼ばれるesbuildのバンドルスクリプト
  • prepack ... buildコマンドを起動するだけ。これは要らないかも?w
  • pack ... 拡張機能のインストーラ(*.vsix)を作成するvsceコマンドを実行するスクリプト。公式参照。

となっています。
普段の開発ではnpm run startでwatchモードを起動し、F5で拡張機能ホストを起動してテスト。ソースを変更したら拡張機能ホストを起動しなおす。という運用になると思います。

packコマンドをscript経由で呼び出すとなぜかインストーラの拡張子がtgzになります。
vsceはターミナルから直接使った方が良さそうです。

もうひとつの特徴的なものがdependencyに登録されているionide-vscode-helpersです。
これはvscodeのF#拡張機能ionideでも使われているvscodeのインタフェースが定義されたファイルです。
TypeScriptの型定義ファイルと同様の役割を持っています。fableでは型定義ファイルとは呼びませんけど...。
詳しくはCall JS from Fableを参照。英語ですけど。

srcディレクトリ

srcディレクトリにはプロジェクトファイルとMain.fsというソースファイルが格納されています。
fableのプロジェクトファイルは通常のF#のプロジェクトファイルです。

<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="../node_modules/ionide-vscode-helpers/src/Fable.Import.VSCode.fs" />
    <Compile Include="Main.fs" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="Fable.Core" Version="3.1.5" />
    <PackageReference Update="FSharp.Core" Version="4.7.2" />
  </ItemGroup>
</Project>

TargetFrameworkはnetstandard2.0となっていますが、ライブラリならnet6.0でも問題ないようです。
fableはビルドされたバイナリからjavascriptへトランスパイルしているようです。F#コンパイラサービスというものを使っているようですが詳しいことはわかりません。興味のある方はfableのソースを眺めてみるのも良いかもしれません 私には無理

Main.fsは以下のようになっています。

module Main

open Fable.Core.JsInterop
open Fable.Import.VSCode.Vscode

let sayHello _ =
    window.showInformationMessage("Hello from Fable!") |> ignore
    None

let activate (context: ExtensionContext) =
    !!commands.registerCommand("fable.sayHello", sayHello)
    |> context.subscriptions.Add

これでコンパイルして実行し、F1キーでコマンドパレットを開くとfable.sayHelloというコマンドが出てくるので実行すると"Hello from Fable!"というメッセージが表示されます。

ソースを簡単に解説すると、

  • module Mainの行でF#のモジュール宣言。
  • Fable.Core.JsInterop、Fable.Import.VSCode.Vscodeをopen
  • sayHelloという関数を定義。windowオブジェクトのshowInfomationMessageメソッドにてメッセージを表示。
  • activate関数の定義。ここでコマンドを登録しています。

vscode extensionの開発に関してはググると色々ヒットすると思うので割愛しまーす。 えへへ
Fable.Import.VSCodeモジュールがionideでも使われているvscodeのインタフェース定義となります。
windowオブジェクトももちろん定義されています。

実践にあたって

プロジェクトテンプレートとしてはミニマルな感じですよね。
あとは実践あるのみ!なわけですが、ブログエディタを開発している過程で学習したことを書いて終わろうと思います。

JSライブラリの使用

Call JS from Fableはよく読みましょう。
ここにはJSライブラリをFableから使用する際の方法や注意点が記載されています。
なにか作るにしても外部JSライブラリを使うことになると思います。
importの仕方、interfaceの書き方、EmitアトリビュートによるF#関数へのマッピングなどを抑えれば開発に取り掛かれると思います。

Call Fable from JavaScript.NET and F# compatibilityもよく読みましょう。
F#の型がjavascriptでは何に当たるのかを知っておく必要があります。

TypeScriptの型定義ファイルからF#のinterfaceを作成してくれるts2fableというツールがありますが、ts2fableで生成されたソースはそのままではまず使えません。
自分の使いたいAPIだけF#でコンパイルが通るようにインタフェースを定義するようにした方が簡単な場合もあります。

時にはjavascriptが必要になる場合もあります。ブログエディタでは使っていませんが、Azure.Storage.TablesをFableから使う際に、javascriptのfor awaitをFableで書く方法がわかりませんでした。
javascriptで関数を書いてFableからはその関数を使用するようにして解決したことがあります。

Promise

Fable.Promiseのドキュメントやソースを見ておいた方が良いでしょう。

async/awaitに相当するpromiseコンピュテーション式を多用することになります。

    // 外部リンクの挿入
    registerCommand "insertOuterLink" (fun () ->
        promise {
            try
                let editor = checkSelectPost()

                let! url = CustomUi.inputOuterLinkUrl()
                match url with
                | Some url -> 
                    printfn $"{url}"

                    let! _ = editor.edit(fun editParam -> 
                        editParam.insert(editor.selection.active, $"[]({url}){{target=\"_blank\"}}")
                    )
                    
                    ()
                | None ->
                    printfn "canceled"
            with
            | MyExtensionException msg -> window.showErrorMessage(msg) |> ignore
            | ex -> window.showErrorMessage(ex.ToString()) |> ignore
        } |> ignore
    
    )

上記はブログエディタのソースの一部ですが、コマンドに登録する関数全体をpromise式で囲んでいます。
let! url = CustomUi.inputOuterLinkUrl()let!がawaitに相当します。

また、VScodeのAPIはThenableを返すものが多いです。ThenableからPromiseに変換する方法がここに記載されています。

ほぼ、そのまんまですが、以下のようなモジュールを作りました。

// https://github.com/fable-compiler/fable-promise/blob/master/docsrc/documentation/computation-expression.md

[<AutoOpen>]
module ThenableExtension

open Fable.Core
open Fable.Import.VSCode

module Thenable =
     // Transform a thenable into a promise
    let toPromise (t: Thenable<'t>): JS.Promise<'t> =  unbox t
     // Transform a thenable from a promise
    let ofPromise (t: JS.Promise<'t>): Thenable<'t> =  unbox t

type Promise.PromiseBuilder with
    /// To make a value interop with the promise builder, you have to add an
    /// overload of the `Source` member to convert from your type to a promise.
    /// Because thenables are trivially convertible, we can just unbox them.
    member x.Source(t: Thenable<'t>): JS.Promise<'t> = Thenable.toPromise t

    // Also provide these cases for overload resolution
    member _.Source(p: JS.Promise<'T1>): JS.Promise<'T1> = p
    member _.Source(ps: #seq<_>): _ = ps

Node.js

Node.jsのAPIはFable.Nodeで定義されています。
Node.js 10.xのAPIなのでちょっと古いですが、ブログエディタの開発には困りませんでした。

fsとかpathはopen Nodeすればそのまま使えるので良いのですが、ブログエディタではchild_processを使う必要が有ったので以下のようにimportして使っています。

open Node

let [<ImportAll("child_process")>] childprocess: ChildProcess.IExports = jsNative

interfaceだけ定義されていて、importされていないものを使う必要が出てきたら自前でimportする必要があるでしょう。
どれがinterface定義だけなのかどうかはソースを見てみる必要がありそうです。


まとめ

vscode extensionをF#で書こうとする人は少ないかもしれませんが、やってみると面白い かもしれません


  1. Fable2まではBabelが利用されていました。

  2. Fableチームのリーダーです。