kotas.tech

こたすの技術的なチラ裏 ( Twitter: @ksaito )

TypeScript でユーザースクリプトを書いた

TypeScript で NicoNicoFavlist というユーザースクリプトを書いたので、その時の備忘録などを記しておきます。

https://github.com/kotas/niconico-favlist

ユーザースクリプトを TypeScript で書く

ユーザースクリプトを動かすためのブラウザ拡張の多くは、オリジナルの先駆者である Greasemonkey と互換性のある API セット を提供しています。

TypeScript で上記の API を扱うのであれば、その定義ファイル (.d.ts) が必要となるのですが、見当たらなかったので自分で作りました。

https://github.com/borisyankov/DefinitelyTyped/blob/master/greasemonkey/greasemonkey.d.ts

(半) 公式リポジトリに pull req も出しておいたらマージされてました。やったね。

ビルドフロー

書いた TypeScript を最終的にユーザースクリプトにビルドする必要があるので、自動化のために grunt を導入しています。

TypeScript -> JS

TypeScript -> JS 変換は grunt-typescript という grunt プラグインがありますので、以下のような感じで設定書いておけば動きます。簡単ですね。

    typescript:
      userscript:
        src:  ['src/userscript/main.ts']
        dest: 'compiled/userscript.js'
        options:
          comments: false

src に指定したファイルが中で <reference> で参照しているファイルも勝手にコンパイル対象となります。src/**/*.js のように指定してもいいんですが、こうすると必要ないファイルの結合を防ぐ事ができます。

dest.js 付きのファイル名にしておけば、1ファイルに結合してくれます。(もちろんファイル構造を維持したまま .ts -> .js の map もできます)

HTML / CSS の埋め込み

ユーザースクリプトでは、最終的に1つの JavaScript ファイルにまとめる必要があるため、HTML や CSS などの静的ファイル類も JS ファイルに埋め込む事にしました。

Handlebars などのテンプレートエンジンを使っても良かったのですが、テンプレートとしてではなく静的ファイルとしてで十分だったので、超簡単なものを自作しました。

色々考えた結果、ファイル結合をしてくれる grunt-contrib-concat を使う事にしました。設定は以下のように書いています。

    concat:
      resources:
        files:
          'compiled/resource.js': ['resources/*.html', 'resources/*.css']
        options:
          banner: "var Resource = { html: {}, css: {} };\n"
          process: (content, path) =>
            name    = path.replace(/^.+\/|\..+$/g, '')
            content = JSON.stringify(content.toString().replace(/^\s+|\s+$/g, '').replace(/\s*\n\s*/g, "\n"))
            if /\.css$/.test(path)
              "Resource.css['#{name}'] = #{content};"
            else
              "Resource.html['#{name}'] = #{content};"

options.banner は結合後のファイルのヘッダーとして付与される文字列です。

options.process は結合されるファイルそれぞれについて、ファイルの内容とパスを引数にして呼び出され、return した文字列が結合される内容として使われます。

上記を実行すると、resources/ 以下の html と csscompiled/resource.js に、以下のような形で出力されます。

var Resource = { html: {}, css: {} };
Resource.html['hello'] = "<div class=\"hello\">\nHello\n</div>";
Resource.html['world'] = "<div class=\"world\">\nworld!\n</div>";
Resource.css['hello'] = ".hello {\ncolor: red;\n}";
Resource.css['world'] = ".world {\ncolor: red;\n}";

あとは、これを以下のように読み込めるようにしました。

var $hello: JQuery = Resource.load('hello');
var $world: JQuery = Resource.load('world');

TypeScript 側のコードは以下です。ポイントは export declare var html のように declare で宣言のみしている事で、これにより上記で作られる JS ファイルでの設定を上書きしないようにしています。

ユーザースクリプト用の加工

ユーザースクリプトとして動かすには、以下の点に気を付ける必要があります。

  • スクリプトを .user.js の拡張子を持つ1ファイルにまとめる
  • 専用のヘッダー をファイルの先頭に付与する
  • グローバル名前空間を極力汚染しない

専用のヘッダーは、そのまま TypeScript ファイルに書いておいてもいいのですが、「ファイルの先頭」である必要があったり、バージョン情報など package.json に書いてある情報と重複したりするので、テンプレートから動的に生成して、コンパイル済み JS ファイルに grunt でくっつける事にしました。

また、グローバル名前空間の汚染については、スクリプト全体を (function() { })() みたいな即時関数で囲えばいいんですが、あいにく TypeScript 単体では出来ないので、これも grunt でやる事にしました。

以下のようなテンプレートを作っておき

etc/userscript/header.txt

// ==UserScript==
// @name           <%= pkg.name %>
// @version        <%= pkg.version %>
// @author         <%= pkg.author %>
// @copyright      <%= pkg.copyright %>
// @description    <%= pkg.description %>
// @namespace      http://example.com/
// @include        http://example.com/
// ==/UserScript==

etc/userscript/intro.txt

(function () {

etc/userscript/outro.txt

})();

同じく grunt-contrib-concat の設定を書き足します。

    concat:
      userscript:
        files:
          'dist/niconicofavlist.user.js': [
            'etc/userscript/intro.txt'
            'compiled/resource.js'
            'compiled/userscript.js'
            'etc/userscript/outro.txt'
          ]
          'dist/niconicofavlist.meta.js': []
        options:
          banner: grunt.file.read 'etc/userscript/header.txt'

options.banner に指定する事で、自動的に grunt.template で処理されるので、テンプレート内の <%= pkg.version %> などは package.json に書いておいた情報がそのまま埋め込まれます。

ついでに、.meta.js というヘッダーのみが含まれるファイルを生成しています。この URL を @updateURL としてヘッダーに書いておくと、@version を見てアップデートを確認してくれます。

終わりに

TypeScript は便利なのでみんな使えばいいと思います><

Node 学園祭では存在感が薄くて残念でしたがw