From 1bfa2a1971c83a8f5ec8dc519c4e558af8fe39f9 Mon Sep 17 00:00:00 2001 From: Grant Timmerman Date: Tue, 17 Jul 2018 19:52:41 -0700 Subject: [PATCH] Add TypeScript support --- README.md | 40 ++++++---- docs/typescript.md | 191 +++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 11 ++- package.json | 1 + src/commands.ts | 3 +- src/files.ts | 50 ++++++++++-- src/utils.ts | 4 +- 7 files changed, 276 insertions(+), 24 deletions(-) create mode 100644 docs/typescript.md diff --git a/README.md b/README.md index 5b24558f..6ae66b68 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ > Develop [Apps Script](https://developers.google.com/apps-script/) projects locally using clasp (*C*ommand *L*ine *A*pps *S*cript *P*rojects). + + ![clasp](https://user-images.githubusercontent.com/744973/35164939-43fd32ae-fd01-11e7-8916-acd70fff3383.gif) **To get started, try out the [codelab](https://g.co/codelabs/clasp)!** @@ -27,6 +29,16 @@ - `slides.js` - `sheets.js` +**[🔷 Write Apps Script in TypeScript](docs/typescript):** Write your Apps Script projects using TypeScript features: +- Arrow functions +- Optional structural typing +- Classes +- Type inference +- Interfaces +- And more... + +Read the [TypeScript guide](docs/typescript) to get started. + ## Install First download `clasp`: @@ -47,20 +59,20 @@ sudo npm i -g grpc @google/clasp --unsafe-perm ```sh clasp ``` -- `clasp login [--no-localhost]` -- `clasp logout` -- `clasp create [scriptTitle] [scriptParentId]` -- `clasp clone ` -- `clasp pull` -- `clasp push [--watch]` -- `clasp open [scriptId]` -- `clasp deployments` -- `clasp deploy [version] [description]` -- `clasp redeploy ` -- `clasp version [description]` -- `clasp versions` -- `clasp list` -- `clasp logs [--json] [--open]` +- [`clasp login [--no-localhost]`](#login) +- [`clasp logout`](#logout) +- [`clasp create [scriptTitle] [scriptParentId]`](#create) +- [`clasp clone `](#clone) +- [`clasp pull`](#pull) +- [`clasp push [--watch]`](#push) +- [`clasp open [scriptId]`](#open) +- [`clasp deployments`](#deployments) +- [`clasp deploy [version] [description]`](#deploy) +- [`clasp redeploy `](#redeploy) +- [`clasp version [description]`](#version) +- [`clasp versions`](#versions) +- [`clasp list`](#list) +- [`clasp logs [--json] [--open]`](#logs) ## How To... diff --git a/docs/typescript.md b/docs/typescript.md new file mode 100644 index 00000000..baaa7f1f --- /dev/null +++ b/docs/typescript.md @@ -0,0 +1,191 @@ +# TypeScript + +[TypeScript](https://www.typescriptlang.org/) is a typed superset of JavaScript that can compile to plain Apps Script. +Using TypeScript with your `clasp` project can allow you to use TypeScript features such as: +- Arrow functions +- Optional structural typing +- Classes +- Type inference +- Interfaces +- And more... + +Clasp `1.5.0` allows **new** and **existing** Apps Script projects to use TypeScript. + +> Note: Once you use TypeScript, you cannot develop on script.google.com (the [transpiled](https://en.wikipedia.org/wiki/Source-to-source_compiler) code). + +## Quickstart + +This quickstart guide describes how to create a TypeScript project from scratch. + +### Prerequisites + +1. Ensure you have upgrade to clasp >= 1.5.0 + - `clasp -v` +1. Install TypeScript definitions for Apps Script in your project's folder. + - `npm i -S @types/google-apps-script` + +### Create the TypeScript Project + +Create a clasp project in an empty directory (or use an existing project): + +```sh +clasp create +``` + +If you haven't already, run `npm i -S @types/google-apps-script` to allow your code editor to autocomplete TypeScript. + +Create a TypeScript file called `hello.ts` with the following contents: + +```ts +const greeter = (person: string) => { + return `Hello, ${person}!`; +} + +function testGreeter() { + const user = 'Grant'; + Logger.log(greeter(user)); +} +``` + +> Note: This is a valid TypeScript file (but an invalid Apps Script file). That's OK. + +### Push the project to the Apps Script server + +Push the TypeScript file to the Apps Script server: + +```sh +clasp push +``` + +> Note: clasp automatically transpiles `.ts` files to valid Apps Script files upon `clasp push`. + +### Verify the project works on script.google.com + +Open the Apps Script project on script.google.com: + +```sh +clasp open +``` + +Notice how a transpiled version of your project was pushed to the Apps Script server. + +Run `testGreeter()` and press `View` > `Logs` to view the logs to see the result. + +## TypeScript Examples + +This section lists TypeScript examples derived from [this guide](https://angular-2-training-book.rangle.io/handout/features/): + +These features allow you to write Apps Script concisely with intelligent IDE errors and autocompletions. + +```ts +// Optional Types +let x: string = 'string'; +let y: string = 'string2'; +const doc: GoogleAppsScript.Document.Document = DocumentApp.create('Hello, world!'); +var foo = (a, b) => a + b; + +const add = function(x: number, y: number): number { + return x + y; +} +add(4_232, 1e3); + +// Classes +class Hamburger { + constructor() { + // This is the constructor. + } + listToppings() { + // This is a method. + } +} + +// Template strings +var name = 'Sam'; +var age = 42; +console.log(`hello my name is ${name}, and I am ${age} years old`); + +// Rest arguments +const add = (a, b) => a + b; +let args = [3, 5]; +add(...args); // same as `add(args[0], args[1])`, or `add.apply(null, args)` + +// Spread operator (array) +let cde = ['c', 'd', 'e']; +let scale = ['a', 'b', ...cde, 'f', 'g']; // ['a', 'b', 'c', 'd', 'e', 'f', 'g'] + +// Spread operator (map) +let mapABC = { a: 5, b: 6, c: 3}; +let mapABCD = { ...mapABC, d: 7}; // { a: 5, b: 6, c: 3, d: 7 } + +// Destructure map +let jane = { firstName: 'Jane', lastName: 'Doe'}; +let john = { firstName: 'John', lastName: 'Doe', middleName: 'Smith' } +function sayName({firstName, lastName, middleName = 'N/A'}) { + console.log(`Hello ${firstName} ${middleName} ${lastName}`) +} +sayName(jane) // -> Hello Jane N/A Doe +sayName(john) // -> Helo John Smith Doe + +// Export (The export keyword is ignored) +export const pi = 3.141592; + +// Google Apps Script Services +var doc = DocumentApp.create('Hello, world!'); +doc.getBody().appendParagraph('This document was created by Google Apps Script.'); + +// Decorators +function Override(label: string) { + return function (target: any, key: string) { + Object.defineProperty(target, key, { + configurable: false, + get: () => label + }); + } +} +class Test { + @Override('test') // invokes Override, which returns the decorator + name: string = 'pat'; +} +let t = new Test(); +console.log(t.name); // 'test' +``` + +After installing `@types/google-apps-script`, editors like Visual Studio Code autocomplete types: + +```ts +var doc = DocumentApp.create('Hello, world!'); +doc.getBody().appendParagraph('This document was created by Google Apps Script.'); +Logger.log(doc.getUrl()); +``` + +In this case, we could write the fully qualified type: + +```ts +const doc: GoogleAppsScript.Document.Document = DocumentApp.create('Hello, world!'); +``` + +Or inferred type: + +```ts +const doc = DocumentApp.create('Hello, world!'); +``` + +In most cases, the inferred type is sufficient for Apps Script autocompletion. + +## How it works + +`clasp push` transpiles ES6+ into ES3 (using [`ts2gas`](https://github.com/grant/ts2gas)) before pushing files to the Apps Script server. + +## Gotchas + +### Advanced Services + +Enableing advanced services modifies the `appsscript.json` file on script.google.com. After enabling an advanced service in the UI, copy the `appsscript.json` file from script.google.com into your editor to use the advanced services in your project. + +### TypeScript Support + +Currently, `clasp` supports [`typescript@2.9.2`](https://www.npmjs.com/package/typescript/v/2.9.2). If there is a feature in a newer TypeScript version that you'd like to support, or some experimental flag you'd like enabled, please file a bug. + +## Further Reading + +- Consider using a linter like [`tslint`](https://github.com/palantir/tslint) to increase the quality of your TypeScript projects. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3bb10ee8..24b32e5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5296,6 +5296,14 @@ } } }, + "ts2gas": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ts2gas/-/ts2gas-1.0.0.tgz", + "integrity": "sha512-NpxrsykcxumKTx7xavzYYUbL60PNzAoJjhMdl5LbsEgXIVfwGNftvMP4pcJt1C1Oyj41/lMJ1EPk5rhCicy0zg==", + "requires": { + "typescript": "2.9.2" + } + }, "tslib": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", @@ -5356,8 +5364,7 @@ "typescript": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.9.2.tgz", - "integrity": "sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w==", - "dev": true + "integrity": "sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w==" }, "ucfirst": { "version": "1.0.0", diff --git a/package.json b/package.json index 4f3d7ae7..01866e1b 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "recursive-readdir": "^2.2.2", "split-lines": "^1.1.0", "string.prototype.padend": "^3.0.0", + "ts2gas": "^1.0.0", "url": "^0.11.0", "watch": "^1.0.2" }, diff --git a/src/commands.ts b/src/commands.ts index 16973fde..29839714 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -4,7 +4,7 @@ import * as del from 'del'; import * as pluralize from 'pluralize'; import { watchTree } from 'watch'; -import { drive, loadAPICredentials, logger, script, discovery } from './auth'; +import { discovery, drive, loadAPICredentials, logger, script } from './auth'; import { fetchProject, getProjectFiles, hasProject, pushFiles } from './files'; import { DOT, @@ -501,6 +501,7 @@ export const status = async (cmd: { json: boolean }) => { } else { console.log(LOG.STATUS_PUSH); filesToPush.forEach((file) => console.log(`└─ ${file}`)); + console.log(); // Separate Ignored files list. console.log(LOG.STATUS_IGNORE); untrackedFiles.forEach((file) => console.log(`└─ ${file}`)); } diff --git a/src/files.ts b/src/files.ts index 2bf3bdae..d5c22ff4 100644 --- a/src/files.ts +++ b/src/files.ts @@ -14,6 +14,7 @@ import { logError, spinner, } from './utils'; +const ts2gas = require('ts2gas'); const path = require('path'); const readMultipleFiles = require('read-multiple-files'); @@ -96,13 +97,24 @@ export function getProjectFiles(rootDir: string, callback: FilesCallback): void } } }); + if (abortPush) return callback(new Error(), null, null); - if(abortPush) return callback(new Error(), null, null); - + // Loop through every file. const files = filePaths.map((name, i) => { + // File name let nameWithoutExt = name.slice(0, -path.extname(name).length); // Replace OS specific path separator to common '/' char nameWithoutExt = nameWithoutExt.replace(/\\/g, '/'); + let type = getAPIFileType(name); + + // File source + let source = contents[i]; + if (type === 'TS') { + // Transpile TypeScript to Google Apps Script + // @see github.com/grant/ts2gas + source = ts2gas(source); + type = 'SERVER_JS'; + } // Formats rootDir/appsscript.json to appsscript.json. // Preserves subdirectory names in rootDir @@ -114,12 +126,40 @@ export function getProjectFiles(rootDir: string, callback: FilesCallback): void nameWithoutExt.length, ); } - if (getAPIFileType(name) && !anymatch(ignorePatterns, name)) { + + /** + * If the file is valid, add it to our file list. + * We generally want to allow for all file types, including files in node_modules/. + * However, node_modules/@types/ files should be ignored. + */ + const isValidFileName = (name: string) => { + let valid = true; // Valid by default, until proven otherwise. + // Has a type or is appsscript.json + let isValidJSONIfJSON = true; + if (type === 'JSON') { + isValidJSONIfJSON = (name === 'appsscript.json'); + } else { + // Must be SERVER_JS or HTML. + // https://developers.google.com/apps-script/api/reference/rest/v1/File + valid = (type === 'SERVER_JS' || type === 'HTML'); + } + // Prevent node_modules/@types/ + if (name.includes('node_modules/@types')) { + return false; + } + const validType = type && isValidJSONIfJSON; + const notIgnored = !anymatch(ignorePatterns, name); + valid = !!(valid && validType && notIgnored); + return valid; + }; + + // If the file is valid, return the file in a format suited for the Apps Script API. + if (isValidFileName(name)) { nonIgnoredFilePaths.push(name); const file: AppsScriptFile = { name: formattedName, // the file base name - type: getAPIFileType(name), // the file extension - source: contents[i], //the file contents + type, // the file extension + source, //the file contents }; return file; } else { diff --git a/src/utils.ts b/src/utils.ts index 93c589a7..65464193 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -134,8 +134,8 @@ export const LOG = { FINDING_SCRIPTS_DNE: 'No script files found.', OPEN_PROJECT: (scriptId: string) => `Opening script: ${scriptId}`, PULLING: 'Pulling files...', - STATUS_PUSH: 'The following files will be pushed by clasp push:', - STATUS_IGNORE: 'Untracked files:', + STATUS_PUSH: 'Not ignored files:', + STATUS_IGNORE: 'Ignored files:', PUSH_SUCCESS: (numFiles: number) => `Pushed ${numFiles} ${pluralize('files', numFiles)}.`, PUSH_FAILURE: 'Push failed. Errors:', PUSH_WATCH: 'Watching for changed files...\n',