ReactでGASのサイドバー等
はじめに
Google Apps Script (GAS)のコードを自分の好きなエディタで開発できるようにするスターターを作りました。
Apps Script in IDE (ASIDE)を参考にしました。
Viteをベースとし、.gs
のコードにTypeScriptを使えるようにしています。
また、独自ページやサイドバー、ダイアログなどのUIのコードにReactを使えるようにしています。
Viteのプラグインを差し替えればVue.jsやPreact、Lit、Svelte、SolidJS、Qwikでも開発できるはずです。
複数のHTMLエントリに対応していることがポイントです。
この記事では、このスターターができるまでの手順を少し説明します。使い方はREADMEを読んでください。
環境
$ node -v
v23.7.0
$ npm ls
vite-react-ts-gas-starter@0.0.0
├── @eslint/js@9.26.0
├── @google/clasp@3.0.3-alpha
├── @rollup/plugin-typescript@12.1.2
├── @types/google-apps-script@1.0.97
├── @types/node@22.15.3
├── @types/react-dom@19.1.3
├── @types/react@19.1.2
├── @vitejs/plugin-react-swc@3.9.0
├── eslint-plugin-react-hooks@5.2.0
├── eslint-plugin-react-refresh@0.4.20
├── eslint@9.26.0
├── globals@16.0.0
├── ncp@2.0.0
├── react-dom@19.1.0
├── react@19.1.0
├── rimraf@6.0.1
├── tslib@2.8.1
├── typescript-eslint@8.31.1
├── typescript@5.7.3
├── vite-plugin-singlefile@2.2.0
└── vite@6.3.4
手順
プロジェクトの作成
まずはプロジェクトを作成します。
npm create vite
.gs
にトランスパイルするためのパッケージの追加
サーバーサイドとなる.gs
ファイルを生成するためのパッケージを追加します。
npm i -D @types/google-apps-script @rollup/plugin-typescript tslib
@types/google-apps-scriptを追加することで、.ts
ファイル内でLoggerなどを書けるようになります。
その.ts
ファイルを.js
ファイルにRollupでトランスパイルするために@rollup/plugin-typescriptを追加します。
asideではrollup-plugin-typescript2が使われていますが、Vite式のtsconfig.json
を正しく読み込めず、Unused '@ts-expect-error' directive.ts(2578)
を発生させていたので使うのをやめました。
tslibは@rollup/plugin-typescriptのpeer dependencyです。
.gs
のもとになるスクリプトの作成
Logger.log
でHello World!するスクリプトを作ります。
// @ts-expect-error TS6133
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const hello = () => {
Logger.log('Hello World!')
}
ここで定義しているhello
は参照されていないものの、使われることがわかっているので、コメントでリンターの例外処理をします。
.gs
にトランスパイルするための構成
Rollupの構成ファイルを作成します。
import { type RollupOptions } from 'rollup'
import typescript from '@rollup/plugin-typescript'
export default {
input: 'src/index.ts',
output: {
dir: 'dist',
format: 'esm',
},
plugins: [typescript()],
context: 'this',
} satisfies RollupOptions
rollup.config.ts
にもtsconfig.json
の内容を適用するため、include
の値を更新します。
- "include": ["vite.config.ts"]
+ "include": ["rollup.config.ts", "vite.config.ts"]
UIのコードをバンドルするためのパッケージの追加
クライアントサイドとなる.html
ファイルを生成するためのパッケージを追加します。
npm i -D @types/node vite-plugin-singlefile ncp rimraf
@types/nodeを追加することで、Node.js上で実行される.ts
ファイル内でPathモジュールなどをインポートできるようになります。
vite-plugin-singlefileはHTML、CSS、JSファイルを単一のHTMLファイルにまとめるためのVite(Rollup)プラグインです。
作者にとって最初のプラグインであり、作者自身何をしているのかわからないらしいので、不安であれば自分で実装しましょう。
ncp、rimrafはそれぞれCLIでファイルをコピー、削除するために使います。
ダイアログとサイドバーのファイルの作成
index.html
を以下のように編集し、src/dialog/index.html
とsrc/sidebar/index.html
にコピーします。
- <script type="module" src="/src/main.tsx"></script>
+ <script type="module" src="main.tsx"></script>
src/main.tsx
も以下のように編集し、src/dialog/main.tsx
とsrc/sidebar/main.tsx
にコピーします。
-import './index.css'
+import '../index.css'
-import App from './App.tsx'
+import App from '../App'
src/
のHTMLをエントリする構成
src/*/index.html
のパスをbuild.rollupOptions.input
に指定します。
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { globSync } from 'node:fs'
import path from 'node:path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
root: path.resolve(__dirname, 'src'),
publicDir: path.resolve(__dirname, 'public'),
build: {
rollupOptions: {
input: globSync(path.resolve(__dirname, 'src/*/index.html')),
},
copyPublicDir: false,
emptyOutDir: false,
outDir: path.resolve(__dirname, 'dest'),
},
})
npx vite
の実行時にアクセスしやすいよう、root
の値もsrc
のパスにしておきます。
publicDir
とoutDir
はデフォルトでroot
からの相対パスになるので、絶対パスで指定します。
バンドル時にはHTMLファイルごとにViteのビルドを実行するため、build.copyPublicDir
とbuild.emptyOutDir
をそれぞれfalse
にします。
その出力を一時的にdest
に入れるようにします。
dest
をGitから除外するようにします。
+dest
vite-plugin-singlefile
の適用
Viteのプラグインとしてvite-plugin-singlefile
を使うようにします。
+import { viteSingleFile } from 'vite-plugin-singlefile'
- plugins: [react()],
+ plugins: [react(), viteSingleFile()],
UIのコードをバンドルするためのスクリプトの追加
vite-plugin-singlefile
は複数のエントリに対応する気がないらしく、エントリ間でのコンポーネントの共有などをするとViteによって.js
ファイルが分割され、正しく単一HTMLファイルが生成されません。
そこで、各HTMLファイルをそれぞれビルドし、それをdist
にまとめるスクリプトを作ります。
import path from 'node:path'
import fs from 'node:fs/promises'
import { exit } from 'node:process'
import { loadConfigFromFile, build } from 'vite'
/** The directory path in which outputs are finally placed. */
const OUTPUT_DIR = 'dist'
const configResult = await loadConfigFromFile({
command: 'build',
mode: 'production',
})
if (configResult === null) {
console.error('Cannot load vite config.')
exit(1)
}
console.log(`Loaded vite config: ${configResult.path}.`)
const baseConfig = configResult.config
/**
* Extract HTML file paths from the Rollup input option.
* @param {import('rollup').InputOption | undefined} input - The Rollup input option.
* @returns {string[]} - An array of HTML file paths.
*/
const getHtmlPaths = input => {
if (Array.isArray(input)) {
return input
}
if (typeof input === 'object') {
return Object.values(input)
}
if (typeof input === 'string') {
return [input]
}
return []
}
const htmlPaths = getHtmlPaths(baseConfig.build?.rollupOptions?.input)
if (htmlPaths.length === 0) {
console.log('No html files to be built found.')
exit(0)
}
const firstOutDir = baseConfig.build?.outDir
if (typeof firstOutDir === 'undefined') {
console.error('Not specified outDir.')
exit(2)
}
/**
* Check if a given path is a directory.
* @param {import('node:fs').PathLike} path - The path to check.
* @returns {Promise<boolean>} - True if the path is a directory, false otherwise.
*/
const directoryExists = path =>
new Promise(resolve =>
fs
.stat(path)
.then(stat => resolve(stat.isDirectory))
.catch(() => resolve(false))
)
const secondOutDir = path.resolve(firstOutDir, '..', OUTPUT_DIR)
if (!(await directoryExists(secondOutDir))) {
await fs.mkdir(secondOutDir)
}
/**
* Copy the build output to the second output directory.
* @param {import('rollup').RollupOutput} param - The Rollup output object.
*/
const copyOutput = ({ output }) => {
console.assert(output.length === 1)
const { fileName } = output[0]
const newFilename = `${path.dirname(fileName)}.html`
const from = path.resolve(firstOutDir, fileName)
const to = path.resolve(secondOutDir, newFilename)
return fs.copyFile(from, to)
}
const promises = htmlPaths.map(async htmlPath => {
const result = await build({
...baseConfig,
build: {
...baseConfig.build,
rollupOptions: {
...baseConfig.build?.rollupOptions,
input: htmlPath,
},
},
})
if (Array.isArray(result)) {
await Promise.all(result.map(copyOutput))
} else if ('output' in result) {
await copyOutput(result)
} else {
console.warn('Unexpected build result type.')
}
})
await Promise.all(promises)
ビルドコマンドの追加
npm run
などでビルドを実行できるようにコマンドを追加します。
- "build": "tsc -b && vite build",
+ "build": "rimraf dist && tsc -b && npm run build:script && npm run build:ui && ncp public dist",
+ "build:script": "rollup --no-treeshake -c rollup.config.ts",
+ "build:ui": "node build.ui.mjs",
GASにプッシュするためのパッケージの追加
生成されたファイルをGASのプロジェクトにアップロードするためのパッケージを追加します。
npm i -D @google/clasp
@google/claspはGASプロジェクトをコマンドラインで操作するツールを提供するパッケージです。
リポジトリの依存関係として追加することで、グローバルにインストールしなくても、リポジトリ内だけなら使えるようになります。
clasp関連のファイルの作成
GASプロジェクトの構成ファイルをpublic
内に作ります。
{
"timeZone": "America/New_York",
"dependencies": {
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8"
}
claspが参照する構成ファイルを作ります。
{
"scriptId": "<SCRIPT_ID>",
"rootDir": "dist",
"scriptExtensions": [
".js",
".gs"
],
"htmlExtensions": [
".html"
],
"jsonExtensions": [
".json"
],
"filePushOrder": [],
"skipSubdirectories": false
}
.clasp.json
にはGASプロジェクトのIDが含まれるため、Gitから除外するようにします。
+.clasp.json
claspコマンドの追加
npm run
などでGASプロジェクトへのプッシュを実行したりプロジェクトページを開いたりできるようにコマンドを追加します。
- "preview": "vite preview"
+ "preview": "vite preview",
+ "push": "npm run lint && npm run build && clasp push -f",
+ "open": "clasp open-script"
おわりに
asideでAngularしか使えないのは不便だなぁと思いました。
Discussion