LoginSignup
8
6

More than 5 years have passed since last update.

Webページに表示されたコードブロックのhtmlをhaml/slim/pugにリアルタイム変換するChrome拡張を作りました

Last updated at Posted at 2018-06-29

開発の背景

デザイナーのリソース確保が難しい場合や自分のサービスを作る場合にエンジニア自身がフロントエンドのコードをそれなりに書くことがよくあります。

個人的にはBulmaが好みですが、Bootstrapが他のエンジニアのキャリアにも合っていて使用頻度は最も高いです。

普段Rails+Vueで開発することが多いのですが、これらのテンプレートエンジンとして
haml/slim/pugを主に使用しています。

それぞれのサイトや派生するCSSフレームワークは多くの場合HTMLでサンプルを掲載していることが多いです。

例えば、Bootstrapの.alertではこんな感じです。
スクリーンショット 2018-06-30 0.08.21.png

そして、このHTMLのコードを試そうとし、エディタに反映するために以下いずれかのステップでテンプレートエンジンに変換を行ってきました。

  1. 以下サイトでHTMLをコピペして利用したいテンプレートの記法に変換してエディタに反映
  2. AtomやVSCodeなどのエディタに.htmlもしくは.erbのファイルを作成してプラグインで採用するテンプレートエンジンにファイル変換する

どちらの方法にも使いたいテンプレートエンジンのコードを手元の環境に反映するために手順があります

  • 前者(既存のテンプレートエンジンのファイルがある場合)
    • サイトのコードブロックを見る
    • 毎回変換対象のサイトに遷移してコード変換
    • 変換結果を手元のエディタに反映
  • 後者(既存のテンプレートエンジンのファイルがない場合)
    • サイトのコードブロックを見る
    • .html or .erbファイルをエディタ上に生成
    • 希望するテンプレートエンジンの記法に変換
      • 場合によっては.html or .erbをデフォルトで残すか削除するかをエディタのプラグインのコンフィグで設定する必要がある

数度だけなら問題ない作業なのですが、これを何十ファイルにも適用するのが面倒で、
それぞれのCSSフレームワークのサイト上にいながら表示されたコードブロックをhaml/slim/pugに変換できれば手順が省略できるなと思いました。

つまり手順は以下に短縮したいです。

  • サイトのコードブロックを見る
  • サイトに表示された変換ボタンをクリックする
  • サイトのコードブロックの表示がhaml/slim/pug記法に変換されて表示される
    • 変換先のテンプレートエンジンの種別はコンフィグで指定が可能
    • コードブロックをセレクタで指定が可能(サイトに寄ってコードブロックのDOM表現が異なることがあるため)
    • 変換ボタン表示をON/OFFで指定可能

絶対同じ事を考えて既に同様のツールがあると思いきや、
自分のメインブラウザであるChromeでこのような変換先のテンプレートエンジンを指定してコードブロックを書き換えるようなものは存在しませんでした。

各々の開発スタイルがあると思うのですが、同じことを考えていたエンジニアさんがいるのかどうか気になっています。
自分だけかもしれませんがこの手順は不便に感じられました。

なので作って自分が捗りたいというのと、まだ誰も作ってない(はず)というモチベーションで開発しました。

成果物

live-html2というChrome拡張になります。
公開してから思いましたが名前が悪いのとせめてパスカルケースの名前にすればいいと思いました。

ストア: https://chrome.google.com/webstore/detail/live-html2/hbcbjlnlbgfbclpkkajlbokohalmgdoc
リポジトリ: https://github.com/j-sakamoto/live-html2

オレンジ色の領域をクリックするとhtmlがコンフィグに基づいて書き換わります。
ezgif.com-resize-compressor.gif

画面キャプチャソフトの関係上Chrome拡張アイコンをクリックした際の表示ができていませんが以下のようなコントロールを用意しました。
スクリーンショット 2018-06-30 1.04.17.png

  • htmlが表示されているコードブロックセレクタ
  • 変換先のテンプレートエンジン名
  • 機能のON/OFF

となります。
将来的に、変換した後のhaml/slim/pug記法にもシンタックスハイライトを適用したいと考えています。

主要な使用技術の選定

Chromeで動作すれば十分なのでChrome拡張を使うようにしました。
変換先のテンプレートエンジンを動的に行うために以下課題をクリアする必要がありました。

選定プロセス

  • html2XXXのような変換エンジンを自分で作るのは時間がかかり過ぎる
    • 変換エンジンはhtml2haml/html2slim/html2pugをそのまま使う
  • Chrome拡張上でrubyに依存するgemやnodeのfsモジュールを利用できない
    • html2haml/html2slimはrubyに依存(Chrome拡張でrubyは実行できない)
    • html2pugはfsモジュールに依存(Chrome拡張はブラウザJSが主要処理となるのでNG)
    • 外部APIにhtml2XXXの処理を委譲する必要がありそう
      • サーバを立ててまで処理したくない
        • Lambda X nodejs環境でruby gemsを実行できればなんとかなりそう
        • デプロイや構成管理を怠惰にしたいのでServerlessFrameworkを使う

選定技術

  • Chrome拡張側
    • Vue.js (機能に対してオーバーですが)
      • ElementUI
    • Parcel(コンフィグレスなビルドツール)
      • エントリーポイントが複数指定できるようになったため、Chrome拡張のようなアイコンやメインプロセスなどエンドポイントが複数あるアーキテクチャにも採用できると思った。
  • API側

LambdaでRubyを実行する際の支障

AWS LambdaでRubyを実行する。それだけじゃなくてgemも実行する。MRIのruby使いたい

これに尽きました。
jruby実装もあるようですし、handlerとしてrubyを実行するケースもあるようです。
html2pugはnpmだけ,それ以外はrubygemsのみで実装されていました。

Lambdaの言語環境としてはnodejsランタイムを使用して、rubyは無理やり動かすというのができるかと調べて見た結果、
同様のことをされている方がいたため周辺記事を掲載します。

これらの事例として各々Lambda上でRubyを使うための前提が

  • ポータブルにrubyのバイナリ実行環境を提供するためにPassengerなどで知られるPhusion製のtraveling-ruby を使っているよ.
    • だけどメンテの大変だからRubyは2.2.2が最新バージョンらしく残念
  • Lambdaはlinux環境だからlocalのmacでbundleしたものでもlinux-x86_64でのnative extensionに依存したgemは動作しないよ(xx.soなどが含まれるもの).
    • html2hamlはnokogiriに依存し、nokogiriはCのビルド環境に依存
    • html2slimはhpricotに依存し、hpricotはC/Javaのビルド環境に依存
    • これらを依存性を解決したパッケージをローカルにコピー/デプロイしLambdaのhandlerでruby_wrapper.shを経由してrubyを実行する

といった仕組みになっていることを知りました。
つまり、gemをインストールするときにインストール対象の環境に依存してC言語のファイルをコンパイルして実行可能ファイルとしてgemがインストールされているということで、当たり前ですがMacでinstallしたgemをデプロイしてLambdaの環境で実行はできないということでした。

現行環境だと以下が詳しいです。
https://guides.rubygems.org/gems-with-extensions/

対処方法

なので @mii-chan さんの具体例でnokogiriについては記事中にあるように、
ローカルのDocker環境にAWS Lambda(amazonlinuxイメージ)のlinux-x86_64のnokogiriをビルドされて、
そこでコンパイルしたgemをローカルのホストマシンにcpし、
ServerlessでLambdaにデプロイということを行われているみたいでした。

traveling-rubyには予めnokogiriのC拡張した部分のみをCDNで配信していて、
こちらが配信パッケージの一覧になっています。

nokogiriありますね。
unicornやyajiなどよく使われるようなgemの拡張についても配信されているようです。

例えばnokogiriについて展開されたtar.gz以下は以下になります。
実際のgemにあるようなrubyのコードは含まれません。
x86_64-linux環境でビルドするときに生成されるnokogiri.soがLambdaでのruby実行に必要なようです。

.
└── extensions
    └── x86_64-linux
        └── 2.2.0-static
            └── nokogiri-1.6.6.2
                ├── gem.build_complete
                ├── gem_make.out
                ├── mkmf.log
                └── nokogiri
                    └── nokogiri.so

逆に、このCDNにないファイルについてはx86_64-linuxでのgem installした結果のパッケージのextensions以下を取り出すしかないみたいです。

先程 html2slimはhpricotに依存し、hpricotはC/Javaのビルド環境に依存 と書いたのですが、
http://d6r77u77i8pq3.cloudfront.net/ には hpricotのビルド済みパッケージが配信されていませんでした

なので、自分でruby 2.2.2環境でgemをinstallしてリポジトリに登録します。

以下のRakeタスクで
https://github.com/j-sakamoto/live-html2/blob/master/serverless/Rakefile

  • mac環境でbundle install
  • bundle install したパスに対して、travering-ruby/nokogiriのextensions/hpricotのextensionsをダウンロードして展開
  • Lambdaから実行するrubyのスクリプトをビルド先にコピー

といったことを行っています。

ruby自体のコードは簡素で、Lambda上のhandlerから順を追うと以下です。

handler.js

1:[handler.js]                                                                                                                                                                                                   
'use strict';                                                                                                                                                                                                    

const html2pug = require("html2pug")
const exec = require("child-process-es6-promise").exec
const shellescape = require('shell-escape');

const pug = (html) => {
  return html2pug(html, { fragment: true })
}

const haml = (html) => {
  const escapedHtml = shellescape([html])
  return execRubyCommand(`./ruby_wrapper.sh haml ${escapedHtml}`)
}

const slim = (html) => {
  const escapedHtml = shellescape([html])
  return execRubyCommand(`./ruby_wrapper.sh slim ${escapedHtml}`)
}

const execRubyCommand = (command) => {
  // こちらで任意のエスケープされたshell commandを実行できるようにしています
  return exec(command).then(result => result.stdout)
}

const buildResponse = (status, data) => {
  return {
    statusCode: status,
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
    body: JSON.stringify(data)
  }
}

const parseBody = (body) => {
  try {
    return JSON.parse(body)
  }
  catch (e) {
    return {}
  }
}

const transcode = (html, dest = "pug") => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      try {
        if (typeof html == "undefined" || typeof html == "null") throw new Error("html required.");

        let transcoder

        if (["pug", "haml", "slim"].includes(dest))
          transcoder = eval(dest); 
        else
          new Error("undefined transcoder.");

        const transcoded = transcoder(html)
        resolve(transcoded)
      }
      catch (e) {
        reject(e)
      }
    }, 0)
  })
}

module.exports.transcode = (event, context, callback) => {
  const { html, dest } = parseBody(event.body)
  transcode(html, dest)
    .then(result => {
      const response = buildResponse(200, { result: result })
      callback(null, response);
    })
    .catch(e => {
      console.log(e)
      callback(401, null)
    })
};                                                                                                                                                                                                               

ruby_wrapper.sh

#!/bin/sh                                                                                                                                                                                                        
set -e

# Figure out where this script is located.
SELFDIR="`dirname \"$0\"`"
SELFDIR="`cd \"$SELFDIR\" && pwd`"

if [ `uname` = 'Darwin' ]; then # ローカルでの開発のためにMac環境とデプロイ先環境で読み込み先を変更しています
  RUBY_PATH=`which ruby`
  APP_ROOT="$SELFDIR/ruby"
  export BUNDLE_GEMFILE="$APP_ROOT/Gemfile"
else
  RUBY_PATH=$SELFDIR/lib/ruby/bin/ruby
  APP_ROOT="$SELFDIR/lib"
  export BUNDLE_GEMFILE="$APP_ROOT/vendor/Gemfile"
fi

# Tell Bundler where the Gemfile and gems are.
unset BUNDLE_IGNORE_CONFIG

# # Run the actual app using the bundled Ruby interpreter, with Bundler activated.
exec $RUBY_PATH -rbundler/setup "$APP_ROOT/app.rb" "$@"

lib/app.rb

#!/usr/bin/env ruby                                                                                                                                                                                              

dist, html = ARGV

res = case dist
when "haml"
  require "html2haml"
  Html2haml::HTML.new(html, ruby19_style_attributes: true).render
when "slim"
  require "html2slim"
  HTML2Slim::HTMLConverter.new(html).to_s
end

これでhtml2haml/html2slimをLambda上に変換することができました。
また、html2pugはnpmで配信されているものをhandlerから使うだけです。

Chrome拡張側

Parcelをつかっているのでコンフィグレスで実装できました。

構成としては以下のようになっていて package.jsonのrun scriptで
main.js, popup.html以下に依存するファイルをsrc/build以下に
展開するようにして開発します。

extension/package.json

"watch": "cp templates/* src/build/ && parcel watch src/js/main.js src/popup.html -d src/build/ --public-url ./",
"build": "cp templates/* src/build/ && parcel build src/js/main.js src/popup.html -d src/build/ --public-url ./"
  • popup.htmlが、Chrome拡張のアイコンをクリックした際に表示されるHTML
    • 設定した内容は入力ごとにLocalStorageへ保存され、その内容をmain.jsへ伝える
  • main.jsがChromeでページ遷移する度に実行されるスクリプト
    • popup.htmlで設定した内容をページロードごとに実行
      1. LocalStorageを参照
      2. selectorに指定された要素の全てを探索
      3. 要素のうちHTMLのタグっぽく始まる要素をターゲットとし、オレンジ色の変換実行ボタンを表示
      4. 変換実行ボタンをクリックするとデプロイ済みのLambdaにテンプレートエンジンの種別とコードブロック内のhtmlを送信
      5. 変換結果を受け取ったらコードブロックの中身をテンプレートエンジンの記法に書き換えする

といった処理を行っています。

スクリーンショット_2018-06-30_3_00_50.png

https://developer.chrome.com/extensions/overview
こちらのOverviewでいうcontentscript.jsがこの拡張のmain.jsになっています。

このようにエントリーポイントを分けてビルドしてできています。
Parcelの恩恵は今後の開発でも受けられそうです。

$ tree extension/src/
src/
├── build
│   ├── element-icons.55002101.ttf
│   ├── element-icons.594fe73f.woff
│   ├── icon.png
│   ├── js
│   │   ├── main.css
│   │   ├── main.js # エントリーポイントのビルド結果
│   │   └── main.map
│   ├── manifest.json
│   ├── popup.538e0a3d.css
│   ├── popup.555c223c.js
│   ├── popup.6352f565.map
│   └── popup.html # エントリーポイントのビルド結果
├── js
│   ├── app.vue
│   ├── components
│   ├── main.js # エントリーポイント
│   ├── popup.js
│   └── popup.vue 
└── popup.html # エントリーポイント

感想、今後について

はじめストアにはこの改装の一つ上をまるごとアップロードしていたのですが、
node_modulesを含め100MBを超えて友人に苦情を食らってしまったので、
src/build以下だけをアップロードするようにし2MB程度のChrome拡張となっています。

使い勝手としては満足しましたが,bundlerを経由してrubyを実行しているため,
テンプレートエンジンによって以下のようなパフォーマンスの差が出ています。

html2pug: 100ms以下
html2haml: 1100ms程度
html2slim: 400ms程度

hamlを一番つかうのですが、gem install時に生成されるshell commandを実行してもさほど
パフォーマンスは向上しなかったためLambda上でのruby実行はやはり少し無理がある感じでした。(ローカルのruby2.5.1環境でも変化なし)

あとは変換後にシンタックスハイライトを適用できるといいなと思っています。

また、使い勝手や機能に関してもご意見いただけるありがたいです。

サーバレスにこのようなアプリを作れるのがいいモチベーションになりましたし、rubygemsの環境依存に対する勉強にもなりました。

8
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
6