clasp を試す
Google スプレッドシートのことを検索していたら「Google Apps Script を TypeSctipt で記述できる」と見かけたので。
インストールは README の Install に従う。
- npm でパッケージをグローバルにインストールするとローカルでコマンドが使えるようになる。
- Apps Script の設定で API の実行を許可しておく。
とくに迷うところもないかな。
あとは以下の通りに進める。
push するところまではできたが、function の import ができない。
ソース
import { t1, C1 } from './test.js'
function test() {
const c = new C1()
c.print()
t1()
}
export const t1 = () => console.log('from t1')
export class C1 {
constructor() {}
print() {
console.log('from t2')
}
}
プッシュしたもの
// Compiled using first-clasp 1.0.0 (TypeScript 4.5.4)
var exports = exports || {};
var module = module || { exports: exports };
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//import { t1, C1 } from './test.js'
function test() {
const c = new C1();
c.print();
(0, test_js_1.t1)();
}
// Compiled using first-clasp 1.0.0 (TypeScript 4.5.4)
var exports = exports || {};
var module = module || { exports: exports };
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.C1 = exports.t1 = void 0;
const t1 = () => console.log('from t1');
exports.t1 = t1;
class C1 {
constructor() { }
print() {
console.log('from t2');
}
}
exports.C1 = C1;
そういうものらしいが、class ができるのはなぜ?
この辺は rollup.js を使うことになりそうなのであまり深くは追及しない。
あとは target
を es2019
にしておいた方がよさそうなので変更した。
V8 support take advantage of the performance boost of Chrome JavaScript engine:
- Every ES2019 features (except ES modules)
- Edit your appsscript.json manifest to choose between the Rhino and V8 engines
- Typescript users should update their tsconfig.json with the "target": "ES2019" compiler option
ローカル側のプロジェクト内の構成など。
- ローカル側のプロジェクトの構成はどうするのがよいのか?
rollup.js などで変換したらsrc
とdist
みたくなりそう。その場合rootDir
はdist
を指すようになる? - Git のリポジトリにする場合は?
含めるもよう。.clasp.json
は含めない方がよさそうな感じがしている。 - テストは?
この後は、この辺も気にしながら進める。
追記、実際に利用されているものを参考にした方がよい。
.mjs(.mts)
は使えないもよう。
$ ls *ts
main.ts test.mts
$ clasp push
└─ /home/node/clasp/first-clasp/appsscript.json
└─ /home/node/clasp/first-clasp/main.ts
Pushed 2 files.
パッケージを EMS にしてみたが clasp push
の挙動に変化はなさそう?
{
"type": "module",
}
{
"module": "es2020",
}
外部パッケージも使いたいので rollup.js を使うことにした。
基本は esmodules.md のとおり。
babel は使わずに以前に dual package 化で使った設定を混ぜる。
- tree-shaking をさせない
- format は esm(
cjs でも動いたがrequire
が混ざるとエラーになる)
にしておけばよさそう。
$ npm install --save-dev rollup @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-typescript tslib
import typescript from '@rollup/plugin-typescript'
import commonjs from '@rollup/plugin-commonjs'
import { nodeResolve } from '@rollup/plugin-node-resolve'
const extensions = ['.ts', '.js']
const preventThreeShakingPlugin = () => {
return {
name: 'no-threeshaking',
resolveId(id, importer) {
if (!importer) {
// let's not theeshake entry points, as we're not exporting anything in Apps Script files
return { id, moduleSideEffects: 'no-treeshake' }
}
return null
}
}
}
export default {
input: './src/main.ts',
output: {
dir: 'build',
format: 'esm'
},
plugins: [
preventThreeShakingPlugin(),
typescript({}),
nodeResolve({
extensions
}),
commonjs({})
]
}
{
"scripts": {
"build": "rollup --config",
},
}
# ignore all files…
**/**
# except the extensions…
!appsscript.json
# and our transpiled code
!build/*.js
# ignore even valid files if in…
.git/**
node_modules/**
ためしに lodash.tosafeinteger
を使ってみる。
import toSafeInteger from 'lodash.tosafeinteger'
import { t1, C1 } from './test.js'
function test() {
const c = new C1()
c.print()
const s = '10.05'
console.log(`${toSafeInteger(s)}`)
t1()
}
$ npm run build
> first-clasp@1.0.0 build /home/node/clasp/first-clasp
> rollup --config
./src/main.ts → build...
created build in 4.1s
$ clasp push
└─ /home/node/clasp/first-clasp/appsscript.json
└─ /home/node/clasp/first-clasp/build/main.js
Pushed 2 files.
function の import も動くようになる。
babel を使う方法も試してみた。
以下のようなエラーが出る。
[!] (plugin babel) Error: You must use the `@babel/plugin-transform-runtime` plugin when `babelHelpers` is "runtime".
以下で対応。
以下インストールなど。
$ npm install --save-dev @rollup/plugin-babel @babel/core @babel/plugin-transform-runtime @babel/preset-env @babel/preset-typescript
import typescript from '@rollup/plugin-typescript'
import commonjs from '@rollup/plugin-commonjs'
import { babel } from '@rollup/plugin-babel'
import { nodeResolve } from '@rollup/plugin-node-resolve'
const extensions = ['.ts', '.js']
// https://github.com/google/clasp/blob/master/docs/esmodules.md
const preventThreeShakingPlugin = () => {
return {
name: 'no-threeshaking',
resolveId(id, importer) {
if (!importer) {
// let's not theeshake entry points, as we're not exporting anything in Apps Script files
return { id, moduleSideEffects: 'no-treeshake' }
}
return null
}
}
}
export default {
input: './src/main.ts',
output: {
dir: 'build',
format: 'esm'
},
plugins: [
preventThreeShakingPlugin(),
//typescript({}),
nodeResolve({
extensions
}),
commonjs({}),
babel({
extensions,
babelHelpers: 'runtime',
skipPreflightCheck: true,
exclude: '**/node_modules/**'
})
]
}
babel.config.js
はそのまま使う。esm パッケージにしているなら .cjs
にする。
実行手順や結果にとくに違いはなさそう。公式の手順に従っておくのが無難かな。
ここまで書いてあれなのでが、スターターがあった。。。
うーん、こちらを使わせていただくか悩む。
現状ためしているものはビルドしたファイルの中は関数がグローバルで記述された状態なので、ちょっと気になっていた。上記スターターだと webpack の設定がされているのでそこは魅力。
が、native EMS パッケージにしたいので(Jest を使うなら ESM で動かしたいので)、やはりボチボチやっていくことに。
Markdown を HTML にしてみた。
unified だとNode.js の Built-in モジュール(path
url
process
)を使っていてその辺が解決できなかったので、mdast と hast の util を使っている。
import { fromMarkdown } from 'mdast-util-from-markdown'
import { toHast } from 'mdast-util-to-hast'
import { toHtml } from 'hast-util-to-html'
function test() {
const mdast = fromMarkdown(`# test
test test test
## test2
test test test
`)
const hast = toHast(mdast)
if (hast) {
const html = toHtml(hast)
console.log(html)
}
}
HTML になっている
native ESM ありでの Jest のテスト。
$ npm install --save-dev "@types/jest" "jest" "ts-jest" "ts-node"
export default {
roots: ['<rootDir>'],
testMatch: [
'**/__tests__/**/*.+(ts|tsx|js)',
'**/?(*.)+(spec|test).+(ts|tsx|js)'
],
collectCoverage: true,
collectCoverageFrom: ['<rootDir>/src/**/*.ts'],
coveragePathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/src/types/'
],
transform: {},
testEnvironment: 'jest-environment-node',
// https://kulshekhar.github.io/ts-jest/docs/next/guides/esm-support/
preset: 'ts-jest/presets/default-esm',
extensionsToTreatAsEsm: ['.ts'],
globals: {
'ts-jest': {
useESM: true
}
},
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1'
}
}
{
"scripts": {
"build": "rollup --config",
"test": "node --experimental-vm-modules node_modules/.bin/jest"
},
}
import { fromMarkdown } from 'mdast-util-from-markdown'
import { toHast } from 'mdast-util-to-hast'
import { toHtml } from 'hast-util-to-html'
export function md2html(md: string) {
const mdast = fromMarkdown(md)
const hast = toHast(mdast)
if (hast) {
const html = toHtml(hast)
return html
}
return ''
}
import { md2html } from '../src/md2html'
describe('md2html()', () => {
it('should return html', () => {
expect(
md2html(`# test1
test test
`)
).toEqual(`<h1>test1</h1>
<p>test test</p>`)
})
})
GitHub Actions でデプロイする場合。
ここでいうデプロイは clasp deploy
でバージョンを付けること。
スクリプトをライブラリとして使う場合の HEAD の更新(clasp push)は手動でやると思う。
Actions でやりたいのは「リポジトリ側でリリースを公開したらclasp push
clasp deploy
で HEAD にバージョンを振るような感じにしたい。
(自作のパッケージで npm publish しているような感じ)
それを実現するにはどうすればよいのか。
clasp のクレデンシャル?は .clasprc
に入っているのだが、わりと短い間隔(たぶん数時間)でリフレッシュトークンを使って更新していもよう。
以前に GitHub Actions でリフレッシュトークン使う方法を調べてみたが良い方法はわからなかった。
今回も無理そうかとおもったら以下がヒットした。
おそらく「ストレージ上に期限切れのアクセストークンで .clasprc
を作成し、clasp xxx
コマンドは実行されたら自動で .clasprc
を更新する機能を利用」しているのだと思う。
この方法で大丈夫?
リフレッシュトークンの有効期間が長いといっても、更新を繰り返して発行されたリフレッシュトークンが増えてくると古いのが消えるとかあったような。
ローカルの .clasprc
は更新されても token.refresh_token
は変わらないようなので大丈夫なのかな。
やはり、import した関数がグローバルに大量にあるのはよろしくない。
例. ライブラリとして利用する場合、コード補完で目的ではない関数が表示される。
webpack (の gas 用プラグイン)を使うのも大変なので少し考える。
以下がヒントになった。
というもので、実は Script Editor 上で異なるタブで開いているコードで定義されている内容は import / export なしにアクセスできるのだ。
- 利用したい関数を
export
する - rollup で
output.format
をumd
にしてoutput.name
に_entrty_point
などを指定 - これで、グローバルの
_entry_piont
にexport
されている関数がセットされるので、それを実行する関数を記述したファイルを作成 -
clasp push
の対象に上記ファイルを含める
候補の関数は 1 つになる
良い点は index.js
はバンドルとは別にコピーするので no-treeshkae
など考慮しなくてもよくなる。
良くない点は関数を別の関数でラップするので少し無駄がある。
あとは this
などが面倒なことになりそうな気もするのでその辺は注意。
code
import { md2html } from './md2html.js'
export function mdToHtml() {
const html = md2html(`# test
test test test
## test2
test test test
`)
console.log(html)
}
/**
* Mardkdown を HTML へ変換.
*
* @param {string} md
* @returns {string}
*/
function mdToHtml(md) {
// これは export しない.
return _entry_point.mdToHtml(md)
}
export default {
input: './src/main.ts',
output: {
dir: 'build',
format: 'iife',
name: '_entry_point'
},
plugins: [
// preventThreeShakingPlugin(),
// typescript({ tsconfig: './tsconfig_rollup.json' }),
nodeResolve({
extensions
}),
babel({
extensions,
babelHelpers: 'runtime',
skipPreflightCheck: true
//exclude: '**/node_modules/**'
}),
commonjs({})
]
}
{
"scripts": {
"clean": "rimraf build/*",
"build": "npm run clean && rollup --config && cp src/index.js build/index.js",
"test": "node --experimental-vm-modules node_modules/.bin/jest"
},
}
clasp 上でライブラリーを利用する場合、コード補完を使おうと思ったら .d.ts
が必要になると思われる。
この辺の対応はとりあえず保留。
上記のグローバルな関数宣言を減らす方法。これはライブラリーの参照先から _entry_point
書き換えることができてしまう。
あまりよろしくない。
いちおう書き換えができなくなるが、泥臭い対応。
index.js
で const _mdToHtml
などに _entry_point
の関数を退避し、delete _entry_point
で削除してしまう。
この方法は以下の特性に依存する。
-
const
はライブラリーの外から参照できない(ように見えるが、明確な記述は見つからなかった) -
filePushOrder
でindex.js
が読み込まれる前に_entry_point
を定義させることができる
いったん定義してしまえばいいだけだが、ちょっと面倒。
const _mdToHtml = _entry_point.mdToHtml
/**
* Mardkdown を HTML へ変換.
*
* @param {string} md
* @returns {string}
*/
function mdToHtml(md) {
return _mdToHtml(md)
}
delete _entry_point
{
"filePushOrder": ["build/main.js", "build/index.js"]
}
以下にしっかり書いてあった。
Only enumerable global properties are visible to library users. This includes function declarations, variables created outside a function with var, and properties explicitly set on the global object. For example, Object.defineProperty() with enumerable set to false creates a symbol you can use in your library, but this symbol isn't accessible by your users.
const
だからというよりも列挙できるかという話らしい。
If you want one or more methods of your script to not be visible (nor usable) to your library users, you can end the name of the method with an underscore. For example, myPrivateMethod_().
また、名前の末尾に _
があれば利用できないとのこと。
ということで _entry_point_
に変更して代入や delete
を外す、スッキリした。
GitHub Actions から clasp push できるようにした。
が、使い続けるかはちょっと悩む。
-
リフレッシュトークンが更新されたときにエラーになる(はず)
secret を設定しなおせばよいのだが。 -
更新されたアクセストークンはマスクされない(ログ上に露出したときに "***" に置き換わらない)
そういうときのためにリフレッシュトークンで更新するのだけど。よろしくはないよなと。
あとはファイルとしてストレージに書き込むのも少しもやっとする。
コンテナが削除されれば消えるけど、うっかりキャッシュに入れると見えてしまう。
.clasprc.json
をファイルに書き出すのではなく Bash の Process Substitution を使ったら「キャッシュされてしまうのは心配」的なことは避けられるのでは?
が、おそらく更新されたトークンを書き込みにくるのでその辺が微妙なところかな。
試すことが多そうなので Process Substitution で read write についてのスクラップつくった。
ダメだった。
キャッシュ云々については参考にしたページのようにホームディレクトリに書き出すことで軽減できそうなのでその辺で対応か。
上記のことを調べていたら以下がヒットした。envsubst の GitHub Actions 版みたいな感じ
(だと思う、実際に試していないので違うかも)。
.json
などの中を SECRET で書き換えたい場合は、jq
で頑張るよりも楽ができそう。
- uses: microsoft/variable-substitution@v1
with:
files: 'Application/*.json, Application/*.yaml, ./Application/SampleWebApplication/We*.config'
env:
Var1: "value1"
Var2.key1: "value2"
SECRET: ${{ secrets.SOME_SECRET }}
Variable Substitution Action が作られたということは需要がある(アンチパターンではない)ということなので、気にしすぎだったかな。
GitHub で Release を publish したら clasp deploy
を実行するようにした。
リリースすると
デプロイされる
課題
- description の指定はどうする。Relase をトリガーにしているので本文の 1 行目などか
- バージョン番号
-
npm version
とどう擦り合わせる? - 開発と公開でプロジェクトを分けた場合ずれる可能性がある
-
リリース本文の 1 行目を description となるようにした。
本文の 1 行目を description 用に記述
description として転記されている
話を蒸し返してしまうけど。
Zenn で GitHub Actions のトピックスを見ていたら以下の記事に興味津々になる。
そのものズバリの内容ではないが、いろいろ難儀しているもよう。
いずれ調べたい。
package.json
とリリース(tag)とスクリプト(プロジェクト)での version と description の関係。
OAuth2 for Apps Script では以下のとおり。
-
package.json
のマイナーバージョン(xx.yy.zz の yy)がスクリプトのバージョンと対応してように見える - リリースのタグがスクリプトバージョンと対応しているように見える(タグは semver 形式ではなく yy のみ)
- リリースのタイトルが description になっているように思える(description は利用者側からは見えない?)
- リリース本文の 1 行目に
package.json
のバージョンが記載されている
これを参考に自作のライブラリーでは以下のようにする予定
- マイナーバージョンの部分をスクリプトのバージョンと対応させる
- タグとリリースのタイトルは npm パッケージのときと同じ
- リリース本文の 1 行目を description にする
開発と公開のプロジェクトをわける場合は以下のとおり
- 動作チェック用にでローカルから開発用プロジェクトに
clasp push
をすることがある - GitHub へ push すると開発用プロジェクトに push する
- リリースを作成すると開発と公開用プロジェクトに push し新しい version で deploy する
OAuth2 for Apps Scrip ではスクリプトの ID でライブラリーとして追加する以外に、ビルドしたソースファイルをコピペしてもいいよ的なことになっている。
Alternatively, you can copy and paste the files in the /dist directory directly into your script project.
よって、上記の場合 dist
もリポジトリに含まれている。
これと同じようにするかちょっと悩んでいた(ビルドしたものをリポジトリに含めるのは面倒そう)。
今回はデプロイのトリガーをリリースにしているので、プロジェクトへプッシュしたファイルとライセンスのファイルをアーカイブして Assets へアップロードするようにした。
Asseets へ md2html.zip
をアップロード
アップロードには以下の Action を利用。
リリースがトリガーなので upload_url
は context から取り出せる。
- name: Upload archive file to release Asset
id: upload-release-asset
if: ${{ matrix.environment == 'rel' }}
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ./md2html.zip
asset_name: md2html.zip
asset_content_type: application/zip
上記でライセンスファイルの作成を行ったのでライセンス関連について。
スクリプトのプロジェクトにライセンスを明記するためのフィールドや慣例があるのかは不明。
少し検索した限りでは触れている情報は見当たらなかった。
プッシュするソースファイルに JSDoc 形式でライセンスを明記してもとくに変化なさそう。
JSDoc 形式でライセンスを明記
デプロイの decription と関数の情報のみ表示される
TypeScript(@types/google-apps-script) とコードエディター上で型の定義が違っている?
- TypeScipt -
GoogleAppsScript.Spreadsheet.Sheet
- コードトエディター -
SpreadsheetApp.Sheet
JSDoc で GoogleAppsScript.Spreadsheet.Sheet
を指定するとコードエディターでは any
と表示される。
コードエディターで SpreadsheetApp.getActiveSheet()
の戻り値を確認すると SpreadsheetApp.Sheet
になる。
clasp はこの辺のことは感知しないという方針のもよう。
上記のように型定義が食い違うとライブラリーとして公開する部分と TypeScript のコードで整合性がとれなくなる(と思う、しばらく悩んでみたが解決策は思いつかなかった)。
強引に index.js
から型定義を作って JSDoc の記述を共有していたが、あきらめて TypeScript 側でも記述することにした。
ライブラリーとして公開される関数。
/**
* スプレッドシートの空行を削除.
* @param { SpreadsheetApp.Sheet } sheet - シート.
* @returns {Array<number>} - 削除した行番号(降順).
*/
function deleteBlankRows(sheet) {
return _entry_point_.BlankRows.deleteBlankRows(sheet)
}
TypeScript 内での namespace 付きの記述。
export namespace BlankRows {
/**
* スプレッドシートの空行を削除.
* @param sheet - シート.
* @returns - 削除した行番号(降順).
*/
export function deleteBlankRows(
sheet: GoogleAppsScript.Spreadsheet.Sheet
): number[] {
const rows = blankRowsInRangeValue(sheet.getDataRange().getValues())
rows.forEach((r) => {
sheet.deleteRows(r, 1)
})
return rows
}
}
上記の .ts
から .d.ts
を作成すると以下のように export
が付いてしまうので型定義のパッケージには使えない(モジュールとして認識されてしまう)。
/// <reference types="@types/google-apps-script" />
export declare namespace BlankRows {
/**
* スプレッドシートの空行を削除.
* @param sheet - シート.
* @returns - 削除した行番号(降順).
*/
function deleteBlankRows(sheet: GoogleAppsScript.Spreadsheet.Sheet): number[];
}
export
を取り除く方法を調べたが最終的には sed
で取り除くことになってしまった。
すったもんだあったが、以下のような個人用スターターができた。
- TypeScript で記述、Jest でテスト
- 公開用と開発用のプロジェクトを設定可能
- 外部の NPM パッケージを利用可能(バンドルされた関数群はライブラリーとして外部に公開されない)
- TypeScript(clasp) でライブラリーを利用するための型定義(
index.d.t
)を生成 - GitHub 上へブランチをプッシュすると開発用プロジェクトへプッシュされる
- GitHub 上でリリースを公開すると開発用と公開用プロジェクトへデプロイ(バージョン付け)される
- リリースの内容に
types
ラベルが付いたプルリクエストがあると型定義のパッケージが npm レジストリで公開される
- リリースの内容に