🗿

ReactでGASのサイドバー等

に公開

はじめに

Google Apps Script (GAS)のコードを自分の好きなエディタで開発できるようにするスターターを作りました。

https://github.com/HotariTobu/vite-react-ts-gas-starter

Apps Script in IDE (ASIDE)を参考にしました。
Viteをベースとし、.gsのコードにTypeScriptを使えるようにしています。

また、独自ページやサイドバー、ダイアログなどのUIのコードにReactを使えるようにしています。
Viteのプラグインを差し替えればVue.jsPreactLitSvelteSolidJSQwikでも開発できるはずです。
複数の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!するスクリプトを作ります。

src/index.ts
// @ts-expect-error TS6133
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const hello = () => {
  Logger.log('Hello World!')
}

ここで定義しているhelloは参照されていないものの、使われることがわかっているので、コメントでリンターの例外処理をします。

.gsにトランスパイルするための構成

Rollupの構成ファイルを作成します。

rollup.config.ts
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の値を更新します。

tsconfig.node.json
-  "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)プラグインです。
作者にとって最初のプラグインであり、作者自身何をしているのかわからないらしいので、不安であれば自分で実装しましょう。

ncprimrafはそれぞれCLIでファイルをコピー、削除するために使います。

ダイアログとサイドバーのファイルの作成

index.htmlを以下のように編集し、src/dialog/index.htmlsrc/sidebar/index.htmlにコピーします。

index.html
-    <script type="module" src="/src/main.tsx"></script>
+    <script type="module" src="main.tsx"></script>

src/main.tsxも以下のように編集し、src/dialog/main.tsxsrc/sidebar/main.tsxにコピーします。

src/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に指定します。

vite.config.ts
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のパスにしておきます。
publicDiroutDirはデフォルトでrootからの相対パスになるので、絶対パスで指定します。

バンドル時にはHTMLファイルごとにViteのビルドを実行するため、build.copyPublicDirbuild.emptyOutDirをそれぞれfalseにします。
その出力を一時的にdestに入れるようにします。

destをGitから除外するようにします。

.gitignore
+dest

vite-plugin-singlefileの適用

Viteのプラグインとしてvite-plugin-singlefileを使うようにします。

vite.config.ts
+import { viteSingleFile } from 'vite-plugin-singlefile'

-  plugins: [react()],
+  plugins: [react(), viteSingleFile()],

UIのコードをバンドルするためのスクリプトの追加

vite-plugin-singlefileは複数のエントリに対応する気がないらしく、エントリ間でのコンポーネントの共有などをするとViteによって.jsファイルが分割され、正しく単一HTMLファイルが生成されません。
そこで、各HTMLファイルをそれぞれビルドし、それをdistにまとめるスクリプトを作ります。

build.ui.mjs
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などでビルドを実行できるようにコマンドを追加します。

package.json
-    "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内に作ります。

public/appsscript.json
{
  "timeZone": "America/New_York",
  "dependencies": {
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8"
}

claspが参照する構成ファイルを作ります。

.clasp.json
{
  "scriptId": "<SCRIPT_ID>",
  "rootDir": "dist",
  "scriptExtensions": [
    ".js",
    ".gs"
  ],
  "htmlExtensions": [
    ".html"
  ],
  "jsonExtensions": [
    ".json"
  ],
  "filePushOrder": [],
  "skipSubdirectories": false
}

.clasp.jsonにはGASプロジェクトのIDが含まれるため、Gitから除外するようにします。

.gitignore
+.clasp.json

claspコマンドの追加

npm runなどでGASプロジェクトへのプッシュを実行したりプロジェクトページを開いたりできるようにコマンドを追加します。

package.json
-    "preview": "vite preview"
+    "preview": "vite preview",
+    "push": "npm run lint && npm run build && clasp push -f",
+    "open": "clasp open-script"

おわりに

asideでAngularしか使えないのは不便だなぁと思いました。

Discussion