GASでReact開発できるようPythonでHTMLにCSS/JSを流し込む


Google Apps Script(GAS)を使ってWeb Applicationを作成した際に、スタイルとスクリプトのインライン化に関し少しつまづいたので、学んだことを記録に残しておきます。

やりたいこと

GASを使ってWeb Appを作るために、.css.jsの中身を.htmlの中にブチ込みたい

Web Applicationを作るのでReact.jsやVue.js、Less.jsといったライブラリを活用したいです。通常ReactやLessを用いた開発ではWebpackでbundle.jsstyle.cssをビルドし、<script src='./bundle.js'><link rel='...'>などのタグでindex.htmlから呼び出すことが多いと思いますが、GASでは、.jsファイルや.cssファイルを持つことができないため、スクリプトやスタイルを全てインライン化する必要があります。

webpackする度にコピーアンドペーストしているわけには行かないので、ビルド時に合わせて処理をしてしまいたいところです。

余談その1:GASで凝ったWeb Application作るってどうなの?
GASの使いどころについては本記事では深くは触れませんが、GASはGoogle Workplaceと相性が良く、簡単にCompliantで比較的SecureなWeb Appを開発で切ることが強みだと思っています。ただし、非機能的な制限が多いため、大規模な開発にはお勧めしません。
小規模な、だけどちょっとリッチなUIを作りたい、というケースにはお勧めです。
また、エンドポイントを1つしか持てないため、基本的にSPA(Single Page Application)として開発することになります。

余談その2:GASのローカル開発について
GASのローカル開発についてはここら辺の記事を参考にしてください。claspというGoogleが提供しているCLIで容易に開発できます。
GAS用のCLIツール clasp を使ってGASをローカルで開発して実行するの巻。
GAS のGoogle謹製CLIツール clasp

つまづいたこと

当初は、Webpackのドキュメントを読み、html-webpack-pluginとhtml-webpack-inline-source-pluginを組み合わせればやりたいことができそう、と思っていました。が、html-webpack-inline-source-pluginがWebpack5には完全に対応しておらず、エラーを吐きまくる上に、StackoverflowでもDosen't work!というコメントばかり。その上ここ2年全く更新がされていないようでした。

解決策

というわけで、「難しい事したいわけじゃないし、いっそ作ってしまえ!」と思い至りPythonでHTMLファイルにbundle.jsstyle.cssを流し込むスクリプトを作成、ビルドの流れに組み込みました。
(Node.jsやWebpackのプラグインを作成した経験がなかったのでPythonで作りました😑)

図にしてみると以下のようになります。index.html, bunlde.js, style.cssを読み込み、HTMLにスタイルとスクリプトを流し込んだ後、新しいindex.htmlを開発環境内に保存します。最後に新しく保存したindex.htmlをGASプロジェクトへclasp pushします。

ここでやることは

  1. インライン化するスクリプトを書く
  2. インライン化スクリプトの実行をnpm-scriptsに組み込む

の2つです。

1. インライン化するスクリプトを書く

bind.pyでは、スタイルシートやスクリプトファイル、テンプレートとなるHTMLファイルを読み込み、HTMLに流し込んだ後、distに結果を書き出すだけで特に特殊なことをしていません。
ただし、流し込む際、スタイルに含まれる特殊文字コードや、スクリプトに含まれるエスケープシーケンス・改行コードを勝手に解釈してしまわないよう気をつける必要があります。re.subのような勝手に改行コードを置換するメソッドの使用を避け、正規表現の検索結果から開始位置と終了位置を取得してから置き換える必要があります。

以下参考までにre.subのバックスラッシュを解釈する処理に関する記述を記載します。

repl が文字列の場合は、その中の全てのバックスラッシュエスケープが処理されます。 \n は 1 つの改行文字に変換され、 \r はキャリッジリターンに変換される、などです。 ASCII 文字のエスケープで未知のものは将来使うために予約されていて、エラーとして扱われます。 それ以外の & のような未知のエスケープは残されます。 \6 のような後方参照は、パターンのグループ 6 がマッチした部分文字列で置換されます。

re.sub | re --- 正規表現操作 — Python 3.9.1 ドキュメント

bind.pyの中身は以下のようになります。
(まだ、リファクタリングの余地がありますが、そこは大目に見てください🐣)

bind.py
#!/usr/bin/python
# coding: utf-8
""" bind.py
.cssと.jsを読み込み、.htmlに流し込みインライン化した.htmlを生成するスクリプト
以下のファイル構造を想定:

/app
+ /dist
 + index.html 事前に作成済のHTMLファイル。ここにスタイルとスクリプトを流し込む
+ /src
 + style.css  流し込むスタイルシート。webpackでlessなどから生成されていることを想定。  
 + bundle.js  流し込むスクリプトファイル。webpackでreactなどから生成されていることを想定。
 + index.html 生成するhtmlファイル。
"""
import os,re

# 各フォルダへの絶対パスを取得
src_folder_path = os.getcwd() + '/src'
dist_folder_path = os.getcwd() + '/dist'

# 絶対パスによる各ファイルへのパスを取得
# スタイルとスクリプトはwebpack後のものを参照するためdist/から
# htmlはバンドル前なのでsrc/のものを参照し、dist/に出力する
css_file_path = dist_folder_path + '/style.css'
js_file_path = dist_folder_path + '/bundle.js'
html_file_path = src_folder_path + '/index.html'
output_file_path = dist_folder_path + '/index.html'


def replaceTag(pattern, tag, html):
  """
  html内でpatternに一致する箇所をtagと置き換える。
  re.subはスクリプトに含まれる改行コード(\n)を勝手に解釈し改行に変換してしまうので
  使用していない。

  Args:
    pattern (string) tagと置き換えるHTML内のDOM要素に一致する正規表現
    tag     (string) 流し込むスタイル/スクリプトを含むDOM要素
    html    (string) 流し込む対象となるHTMLファイルの中身
  """
  match_obj = re.search(pattern, html)
  [start, end] = match_obj.span()
  html = html[:start] + tag + html[end:]
  return html

if __name__ == '__main__':
  # style, script, htmlを読み取る。style, scriptはタグで挟んでおく
  with open(css_file_path, encoding='utf_8') as f:
    style = f.read()
    style_tag = '<style>' + style + '</style>'

  with open(js_file_path, encoding='utf_8') as f:
    script = f.read()
    script_tag = '<script>' + script + '</script>'

  with open(html_file_path, encoding='utf_8') as f:
    html = f.read()

  # <link rel="stylesheet">をstyle.cssをインライン化したstyle要素で置換
  pattern = r'<link rel="stylesheet".+?>'
  html = replaceTag(pattern, style_tag, html)
  # <script></script>をbundle.jsをインライン化したscript要素で置換
  pattern = r'<script.+?/script>'
  html = replaceTag(pattern, script_tag, html)

  # インライン化したHTMLを書き出す
  with open(output_file_path, encoding='utf_8', mode='w') as f:
    f.write(html)

2. インライン化スクリプトの実行をnpm-scriptsに組み込む

あとはスクリプトの実行をnpm-scriptsに組み込むだけです。ついでに、webpackclasp pushも入れてしまってコマンド1つでreact/lessのビルドからGASへのプッシュができるようにしちゃいます。

package.jsonに以下の一行を追加します。

package.json
//省略
"scripts": {
  //省略
  "build": "webpack & python ./bind.py & clasp push;"
},
//省略

これでnpm run buildを実行するだけで、GASへのプッシュができるようになります。

まとめ

GASでReact使いたかったけど、インライン化するwebpackのpluginがなさそうだったのでpythonで作りました!という話でした。

本来ならnode.jsやwebpackのpluginとして作った方が管理として望ましいので、落ち着いたら挑戦してみようと思います。