LoginSignup
37
32

More than 5 years have passed since last update.

Play FrameworkのアセットコンパイルをGulpにすべておまかせ

Last updated at Posted at 2015-08-14

play-gulp.png

追記:Herokuに簡単にデプロイできるようにしました。(2015/9/28)
https://github.com/mmizutani/play-gulp-standalone
https://play-gulp-standalone.herokuapp.com

sbt-webからGulpへ

Play Frameworkでは、スタティックアセットはapp/assetsフォルダからsbt-webプラグインでコンパイルされたものとpublic/フォルダにあるファイルがjarにまとめられてクライアントに送るかたちになっています。Play Frameworkを使い始めてしばらくの間はsbt-webのアセットコンパイルで充分で、bower/npmのパッケージを取りこめるようになったWebJarのエコシステムにも満足していたのですが、やれTypeScriptだやれReactだなどといってフロントエンドがどんどん複雑化してくると、sbt-webのプラグイン群(sbt-rjs, autoprefixerなど)の中には長期間更新のないものも散見される現状では心許なくなってきました。

ということで、今回、Playアプリのアセットコンパイルを人気のJavaScriptタスクランナーGulpにまるごと乗り換えられるようにしてみました。基本的には、sbtからGruntを呼び出しつつスタティックアセットへのアクセスをyeomanのジェネレータで作成したhtmlプロジェクトにリダイレクトする方法をとっているPlayプラグインplay-yeomanのやり方に倣いつつ、GruntをGulpに置き換え、かつプラグインもサブプロジェクトも不要な形に簡素化しました。プロジェクトの細かい微調整は必要ですが、新規に作成する必要があるScalaファイルは2つのみです。

ささっと使ってみたい方は[こちらのサンプルプロジェクト https://github.com/mmizutani/play-gulp-standalone をcloneして、普通のPlayプロジェクト同様にsbt run, sbt ~run, sbt test, sbt testProd, sbt ";stage;dist"(Typesafe Activatorならactivator run, activator ~run, activator test, activator testProd, activator ";stage;dist") などをしてみてください。

設定

0. 前提

Scala, sbt(またはTypesafe Activator), Node.js (node & npm)をインストール済。

1. プロジェクト作成

まずgenerator-gulp-angularをインストールし、

$ npm install -g yo gulp bower
$ npm install -g generator-gulp-angular

テンプレートからプレーンなPlayプロジェクトを作成します。プロジェクトの名称はここではとりあえずplay-scalaとしておきます。

$ activator new play-gulp
Choose from these featured templates or enter a template name:
  1) minimal-akka-java-seed
  2) minimal-akka-scala-seed
  3) minimal-java
  4) minimal-scala
  5) play-java
  6) play-scala
(hit tab to see a list of all templates)
> 6
OK, application "play-gulp" is being created using the "play-scala" template.

そして、rm -rf publicで、プロジェクトルートのpublicフォルダとはおさらばします。代わりにプロジェクトルートにuiフォルダを作成して、この中にさきほどインストールしたyeomanのジェネレータの1つ、gulp-angularでプロジェクト雛形を作成します。(gulpのプロジェクト雛形であれば他のyeomanジェネレータでも以降の設定方法はほぼ同じなはずです。)

$ cd play-gulp
$ rm -rf public
$ mkdir ui && cd $_
$ yo gulp-angular

ディレクトリ構成はこうなります。
Selection_012.png

2. project/PlayGulp.scalaの作成

projectフォルダ内にPlayGulp.scalaファイルを新規作成し、Gulpのタスクをsbtのタスクに紐付けてsbtコンソールから呼び出せるようにするための設定をします。

project/PlayGulp.scala
import com.typesafe.sbt.packager.universal.UniversalPlugin.autoImport._
import com.typesafe.sbt.web.Import._
import play.sbt.PlayImport.PlayKeys._
import play.sbt.PlayRunHook
import play.twirl.sbt.Import.TwirlKeys
import sbt.Keys._
import sbt._

object PlayGulp {

  lazy val gulpDirectory = SettingKey[File]("gulp-directory", "gulp directory")
  lazy val gulpFile = SettingKey[String]("gulp-file", "gulpfile")
  lazy val gulpExcludes = SettingKey[Seq[String]]("gulp-excludes")
  lazy val gulp = InputKey[Unit]("gulp", "Task to run gulp")
  lazy val gulpBuild = TaskKey[Int]("gulp-dist", "Task to run dist gulp")
  lazy val gulpClean = TaskKey[Unit]("gulp-clean", "Task to run gulp clean")
  lazy val gulpTest = TaskKey[Unit]("gulp-test", "Task to run gulp test")

  val playGulpSettings: Seq[Setting[_]] = Seq(

    // Specifies the location of the root directory of the Gulp project relative to the Play app root
    gulpDirectory <<= (baseDirectory in Compile) { _ / "ui" },

    gulpFile := "gulpfile.js",

    gulp := {
      val base = (gulpDirectory in Compile).value
      val gulpfileName = (gulpFile in Compile).value
      runGulp(base, gulpfileName, Def.spaceDelimited("<arg>").parsed.toList).exitValue()
    },

    gulpBuild := {
      val base = (gulpDirectory in Compile).value
      val gulpfileName = (gulpFile in Compile).value
      val result = runGulp(base, gulpfileName, List("build")).exitValue()
      if (result == 0) {
        result
      } else throw new Exception("gulp failed")
    },

    gulpClean := {
      val base = (gulpDirectory in Compile).value
      val gulpfileName = (gulpFile in Compile).value
      val result = runGulp(base, gulpfileName, List("clean")).exitValue()
      if (result != 0) throw new Exception("gulp failed")
    },

    gulpTest := {
      val base = (gulpDirectory in Compile).value
      val gulpfileName = (gulpFile in Compile).value
      val result = runGulp(base, gulpfileName, List("test")).exitValue()
      if (result != 0) throw new Exception("gulp failed")
    },

    // Executes `gulp build` before `sbt dist`
    dist <<= dist dependsOn gulpBuild,

    // Executes `gulp build` before `sbt stage`
    stage <<= stage dependsOn gulpBuild,

    // Executes `gulp clean` before `sbt clean`
    clean <<= clean dependsOn gulpClean,

    // Executes `gulp test` before `sbt test` (optional)
    //(test in Test) <<= (test in Test) dependsOn gulpTest,

    // Ensures that static assets in the ui/dist directory are packaged into
    // target/scala-2.11/play-gulp_2.11-1.0.0-web-asset.jar/public when the play app is compiled
    unmanagedResourceDirectories in Assets <+= (gulpDirectory in Compile)(_ / "dist"),

    // Starts the gulp watch task before sbt run
    playRunHooks <+= (gulpDirectory, gulpFile).map {
      (base, fileName) => GulpWatch(base, fileName)
    },

    // Allows all the specified commands below to be run within sbt in addition to gulp
    commands <++= gulpDirectory {
      base =>
        Seq(
          "npm",
          "bower",
          "yo"
        ).map(cmd(_, base))
    }
  )

  val withTemplates: Seq[Setting[_]] = Seq(
    // Added ui/src/views/*.scala.html to the target of Scala view template compilation
    sourceDirectories in TwirlKeys.compileTemplates in Compile <+= (gulpDirectory in Compile)(_ / "src"),
    includeFilter in sources in TwirlKeys.compileTemplates := "*.scala.html",
    gulpExcludes <<= gulpDirectory(gd => Seq(
      gd + "/src/app/",
      gd + "/src/assets/",
      gd + "/src/bower_components/"
    )),
    excludeFilter in unmanagedSources <<=
      (excludeFilter in unmanagedSources, gulpExcludes) {
        (currentFilter: FileFilter, ge) =>
          currentFilter || new FileFilter {
            def accept(pathname: File): Boolean = {
              (true /: ge.map(s => pathname.getAbsolutePath.startsWith(s)))(_ && _)
            }
          }
      },
    // Makes play autoreloader to compile Scala view templates and reload the browser
    // upon changes in the view files ui/src/views/*.scala.html
    // Adds ui/src/views directory's scala view template files to continuous hot reloading
    watchSources <++= gulpDirectory map { path => ((path / "src/views") ** "*.scala.html").get}
  )

  private def runGulp(base: sbt.File, fileName: String, args: List[String] = List.empty): Process = {
    if (System.getProperty("os.name").startsWith("Windows")) {
      val process: ProcessBuilder = Process("cmd" :: "/c" :: "gulp" :: "--gulpfile=" + fileName :: args, base)
      println(s"Will run: ${process.toString} in ${base.getPath}")
      process.run()
    } else {
      val process: ProcessBuilder = Process("gulp" :: "--gulpfile=" + fileName :: args, base)
      println(s"Will run: ${process.toString} in ${base.getPath}")
      process.run()
    }
  }

  import scala.language.postfixOps

  private def cmd(name: String, base: File): Command = {
    if (!base.exists()) {
      base.mkdirs()
    }
    Command.args(name, "<" + name + "-command>") {
      (state, args) =>
        if (System.getProperty("os.name").startsWith("Windows")) {
          Process("cmd" :: "/c" :: name :: args.toList, base) !<
        } else {
          Process(name :: args.toList, base) !<
        }
        state
    }
  }

  object GulpWatch {

    def apply(base: File, fileName: String): PlayRunHook = {

      object GulpSubProcessHook extends PlayRunHook {

        var process: Option[Process] = None

        override def beforeStarted(): Unit = {
          process = Some(runGulp(base, fileName, "watch" :: Nil))
        }

        override def afterStopped(): Unit = {
          process.foreach(_.destroy())
          process = None
        }
      }

      GulpSubProcessHook
    }

  }

}

build.sbtでこの設定をプロジェクトに読み込みます。

build.sbt
import PlayGulp._

...

lazy val root = (project in file("."))
  .enablePlugins(PlayScala)
  .settings(playGulpSettings ++ withTemplates) //この行を追記

3. app/controllers/GulpAssets.scalaの作成

続いて、スタティックアセットへのアクセスをuiフォルダのgulp-angularプロジェクトにルーティングするためのGulpAssetsコントローラを作成します。

app/controllers/GulpAssets.scala
package controllers

import java.io.File
import javax.inject.Singleton

import play.api.Play.current
import play.api.{Logger, _}
import play.api.http.DefaultHttpErrorHandler
import play.api.libs.concurrent.Execution.Implicits.defaultContext
import play.api.mvc.{Action, AnyContent}

import scala.concurrent.Future

object GulpAssets extends controllers.Assets(DefaultHttpErrorHandler) {

  private lazy val logger = Logger(getClass)

  def index = Action.async { request =>
    if (request.path.endsWith("/")) {
      at("index.html").apply(request)
    } else {
      Future(Redirect(request.path + "/"))
    }
  }

  // List of UI directories from which static assets are served in development mode
  // (A directory in higher priority comes first.)
  val devBasePaths: List[java.io.File] = List(
    Play.application.getFile("ui/.tmp/serve"),
    Play.application.getFile("ui/src"),
    Play.application.getFile("ui")
  )

  def devAssetHandler(file: String): Action[AnyContent] = Action { request =>
    // Generates a non-strict list of the full paths
    val targetPaths = devBasePaths.view map {
      new File(_, file)
    }

    // Generates responses returning the file in the dev and test modes only (not in the production mode)
    val responses = targetPaths filter { file =>
      file.exists()
    } map { file =>
      if (file.isFile) {
        logger.info(s"Serving $file")
        Ok.sendFile(file, inline = true).withHeaders(CACHE_CONTROL -> "no-store")
      } else {
        Forbidden(views.html.defaultpages.unauthorized())
      }
    }

    // Returns the first valid path if valid or NotFound otherwise
    responses.headOption getOrElse NotFound("404 - Page not found error\n" + request.path)
  }

  def prodAssetHandler(file: String): Action[AnyContent] = Assets.at("/public", file)

  lazy val atHandler: String => Action[AnyContent] = if (Play.isProd) prodAssetHandler(_: String) else devAssetHandler(_: String)

  def at(file: String): Action[AnyContent] = atHandler(file)

}

@Singleton
class GulpAssets extends controllers.Assets(DefaultHttpErrorHandler) {
  def at(file: String) = GulpAssets.at(file: String)
}

4. conf/routesの変更

デフォルトで設定されているスタティックアセット用のルーティング(Assets.versioned)は削除し、かわりに先ほど作成したGulp用のスタティックアセットのルーティングの設定(GulpAssets.at)を一番下に加えます。

routes
GET        /                           controllers.Application.index
GET        /oldhome                    controllers.Application.oldhome
GET        /*file                      controllers.GulpAssets.at(file)

#GET     /assets/*file               controllers.Assets.versioned(path="/public", file: Asset)

routeを修正したので、viewsのテンプレートファイルも合わせて修正します。

Before

app/views/main.scala.html
@(title: String)(content: Html)

<!DOCTYPE html>

<html lang="en">
  <head>
    <title>@title</title>
    <link rel="stylesheet" media="screen" href="@routes.Assets.versioned("stylesheets/main.css")">
    <link rel="shortcut icon" type="image/png" href="@routes.Assets.versioned("images/favicon.png")">
    <script src="@routes.Assets.versioned("javascripts/hello.js")" type="text/javascript"></script>
  </head>
  <body>
    @content
  </body>
</html>

After

app/views/main.scala.html
@(title: String)(content: Html)

<!DOCTYPE html>

<html lang="en">
  <head>
    <title>@title</title>
    <link rel="stylesheet" media="screen" href="@routes.GulpAssets.at("assets/styles/oldhome-style.css")">
    <link rel="shortcut icon" type="image/png" href="@routes.GulpAssets.at("assets/favicon.png")">
    <script type="text/javascript" src="@routes.GulpAssets.at("assets/scripts/oldhome-script.js")"></script>
  </head>
  <body>
    @content
  </body>
</html>

上記のroutes.GulpAssets.at("assets/~/~.xxx")の実パスはui/src/assets/~/~.xxxに相当するので、この場所に適当にoldhome-style.cssやoldhome-script.jsをつくっておきます。

$ touch ui/src/assets/styles/oldhome-style.css
$ touch ui/src/assets/scripts/oldhome-script.js

そしてApplication.indexはGulpAssets.indexに設定して、URLのルート/へのアクセスは、開発時はui/.tmp/serve/index.htmlにリダイレクトされるようにします。従来のindexページは適当に/oldhomeでアクセスできるようにしておきます。

app/controllers/Application.scala
class Application extends Controller {

  def index = GulpAssets.index

  def oldhome = Action {
    Ok(views.html.index("Play Framework"))
  }

}

5. gulp-angularプロジェクトの微修正

さすがにgulp-angularのプロジェクト構成そのままではPlayアプリと統合できないので、以下を修正します。

  • ui/.bowerrc

Before

ui/.bowerrc
{
  "directory": "bower_components"
}

After

ui/.bowerrc
{
  "directory": "src/bower_components"
}
  • bower_componentsはsrcフォルダの中へ移動
mv ui/bower_components ui/src/bower_components
  • ui/gulp/conf.js

Before

ui/gulp/conf.js
exports.wiredep = {
  exclude: [/bootstrap.js$/, /bootstrap-sass-official\/.*\.js/, /bootstrap\.css/],
  directory: 'bower_components'
};

After

ui/gulp/conf.js
exports.wiredep = {
  exclude: [/bootstrap.js$/, /bootstrap-sass-official\/.*\.js/, /bootstrap\.css/],
  directory: 'src/bower_components'
};
  • ui/gulp/build.js

Before

ui/gulp/build.js
gulp.task('fonts', function () {
  return gulp.src($.mainBowerFiles())
    .pipe($.filter('**/*.{eot,svg,ttf,woff,woff2}'))
    .pipe($.flatten())
    .pipe(gulp.dest(path.join(conf.paths.dist, '/fonts/')));
});

...

gulp.task('build', ['html', 'fonts', 'other']);

After

ui/gulp/build.js
gulp.task('fonts', function () {
  return gulp.src(path.join(conf.paths.src, '/bower_components/**/*'))
    .pipe($.filter('**/*.{eot,svg,ttf,woff,woff2}'))
    .pipe($.flatten())
    .pipe(gulp.dest(path.join(conf.paths.dist, '/fonts/')));
});

gulp.task('otherassets', function () {
  return gulp.src(path.join(conf.paths.src, '/assets/**/*'))
    .pipe(gulp.dest(path.join(conf.paths.dist, '/assets/'), {overwrite: false}));
});

...

gulp.task('build', ['html', 'fonts', 'other', 'otherassets']);

必須ではないですが、各スタティックファイルがどのディレクトリからクライアントに送信させているのかがコンソールにリアルタイムに表示されたほうが慣れるまではいいと思うので、logの出力レベルをERRORからINFOに緩めておきます。

conf/logback.xml
...
<root level="INFO">
  <appender-ref ref="STDOUT" />
</root>
...

これでプロジェクト設定は完了です。

使い方

sbt/activatorのコンソールに入ると、

$ sbt/activator

build, clean, serve, testなどのgulpのタスクをuiディレクトリをルートとして実行できるようになっています。gulpのデフォルトタスク(ここではgulp build)を実行すると、ui/buildディレクトリにng-annotate, uglify等がされたその他スタティックファイルと、JavaScript/CSSをinjectされたindex.htmlファイルができます。

[play-gulp] > gulp
Will run: [gulp, --gulpfile=gulpfile.js, build] in /home/user/dev//play-gulp/ui
[23:41:29] Using gulpfile ~/dev/play-gulp/ui/gulpfile.js
[23:41:29] Starting 'clean'...
[23:41:29] Finished 'clean' after 53 ms
[23:41:29] Starting 'default'...
[23:41:29] Starting 'scripts'...
[23:41:29] Starting 'styles'...
[23:41:29] Starting 'partials'...
[23:41:29] Starting 'fonts'...
[23:41:29] Starting 'other'...
[23:41:29] Starting 'otherassets'...
[23:41:29] Finished 'default' after 480 ms
[23:41:29] gulp-inject 2 files into index.scss.
[23:41:31] Finished 'styles' after 1.87 s
[23:41:31] all files 7.88 kB
[23:41:31] Finished 'scripts' after 2.04 s
[23:41:31] Starting 'inject'...
[23:41:31] Finished 'partials' after 1.73 s
[23:41:31] gulp-inject 1 files into index.html.
[23:41:31] Finished 'otherassets' after 1.64 s
[23:41:31] gulp-inject 10 files into index.html.
[23:41:31] Finished 'inject' after 121 ms
[23:41:31] Starting 'html'...
[23:41:31] gulp-inject 1 files into index.html.
[23:41:40] 'dist/' styles/app-c90c023e53.css 120.34 kB
[23:41:40] 'dist/' styles/vendor-1dddaadd0b.css 58.62 kB
[23:41:40] 'dist/' scripts/app-10800ada38.js 6.26 kB
[23:41:40] 'dist/' scripts/vendor-48a98d1612.js 438.69 kB
[23:41:40] 'dist/' index.html 636 B
[23:41:40] 'dist/' all files 624.56 kB
[23:41:40] Finished 'html' after 8.83 s
[23:41:40] Finished 'other' after 11 s
[23:41:40] Finished 'fonts' after 11 s
[23:41:40] Starting 'build'...
[23:41:40] Finished 'build' after 7.29 μs

runにより開発モードでアプリケーションを実行すると、PlayGulp.scalaの設定

project/PlayGulp.scala
...
playRunHooks <+= (gulpDirectory, gulpFile).map {
      (base, fileName) => GulpWatch(base, fileName)
    }
...

によりgulp watchタスクが呼ばれ、http://localhost:9000 を開くと、

[play-gulp] > run

Will run: [gulp, --gulpfile=gulpfile.js, watch] in /home/user/dev/play-gulp/ui
--- (Running the application, auto-reloading is enabled) ---

[info] p.a.l.c.ActorSystemProvider - Starting application default Akka system: application
[info] p.c.s.NettyServer - Listening for HTTP on /localhost:9000

(Server started, use Ctrl+D to stop and go back to the console...)

Yeomanのシンボルキャラクターのひげおじさんと昔の人気British sitcomのタイトルが飾る(なぜこのタイトルがここで出てくるのか理由は知りません...)トップ画面になります。

Selection_014.png

ターミナルを見てみると、GulpAssetsコントローラ経由でどこのパスからファイルがserveされているかわかります。

[23:52:50] Using gulpfile ~/dev/play-gulp/ui/gulpfile.js
[23:52:50] Starting 'scripts'...
[23:52:50] Starting 'styles'...
[23:52:50] gulp-inject 2 files into index.scss.
[23:52:51] all files 7.88 kB
[23:52:51] Finished 'scripts' after 530 ms
[23:52:52] Finished 'styles' after 1.43 s
[23:52:52] Starting 'inject'...
[23:52:52] gulp-inject 1 files into index.html.
[23:52:52] gulp-inject 10 files into index.html.
[23:52:52] Finished 'inject' after 61 ms
[23:52:52] Starting 'watch'...
[23:52:52] Finished 'watch' after 15 ms
...
[info] - play.api.Play - Application started (Dev)
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/.tmp/serve/index.html
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/src/bower_components/toastr/toastr.css
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/src/bower_components/angular-animate/angular-animate.js
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/.tmp/serve/app/index.css
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/src/bower_components/animate.css/animate.css
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/src/bower_components/angular/angular.js
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/src/bower_components/jquery/dist/jquery.js
...

見えているトップページの実体は、デバッグ時にGulpが一時的に生成するui/.tmp/serve/index.htmlです。

デバッグをやめて、sbt(またはactivator)のstageタスクを実行すると、

[play-gulp] > stage

target/scala-2.11/play-gulp_2.11-1.0-web-assets.jarがほかのjarファイルとともに生成されます。play-gulp_2.11-1.0-web-assets.jarをzipファイルとして解凍すると、Gulpによりビルドされたスタティックファイルが入っているui/distフォルダの中身がそのままそっくりjar内のpublicフォルダにコピーされているのがわかります。

Selection_015.png

このjar/publicには本来はプロジェクトルートのpublicフォルダ内のスタティックファイルがコピーされますが、このプロジェクトでは、プロジェクトルート/publicフォルダを削除して、代わりに下記のプロジェクト設定

project/PlayGulp.scala
unmanagedResourceDirectories in Assets <+= (gulpDirectory in Compile)(_ / "dist")

を追加しているため、このようになります。

Gulpでビルドしたスタティックアセットは、最終的にはdistタスクにより、デプロイ用ファイル一式target/universal/play-gulp-1.0.zipの中のlibに格納されます。

リリースモードで実行し、ブラウザでディベロッパーツールを開くと、JavaScript/CSSがきちんとminify, concatenate, obfuscateされているのがわかります。

[play-gulp] > testProd

Selection_016.png

app/views/~.scala.htmlのテンプレートからスタティックアセットにアクセスしたい時は、JavaScript, CSS, PDF, 画像、音楽ファイルなど(たとえばphoto.png)をui/src/assets以下のディレクトリに置いておけば、GulpAssetsコントローラ経由で開発時もリリース時もアクセスできます。(routesで/をgulp-angularのトップページに設定しているので、この設定のままであればGulpAssetsを使わず"/assets/images/photo.png"でも可。)

app/views/~.scala.html
<img src="@routes.GulpAssets.at("assets/images/photo.png")">
<img src="/assets/images/photo.png">
app/views/~.scala.html
<img src="@routes.Assets.at("assets/images/photo.png")">
<img src="/assets/images/photo.png">

サーバサイドとフロントエンドの分離開発

このプロジェクト構成のいいところとして、Gulpをフルに使える点に加えて、サーバサイドとフロントエンドの分離開発がしやすいという点も挙げられます。

プロジェクトルートのuiフォルダ内は一般的なGulpプロジェクトなので、Play Frameworkプロジェクトには触らず、uiディレクトリ内のgulp-angularプロジェクトだけをgulpコマンドで開発していくことができます。

$ cd ui
$ gulp clean
$ gulp serve
$ gulp test
$ gulp build

ui以下をgit submoduleとして管理しておくと、デザイナーさんとの分業がはかどると思います。

その他

GitHubにあげたサンプルプロジェクトでは、おまけとして、AngularJSのhtml5modeを有効にしたり、twirlのScala htmlテンプレートファイルをui/src/views/~~/~.scala.html に置いてみたり、ApplicationコントローラにREST APIっぽいアクションを追加してみたりしています。開発モードで http://localhost:9000/routes からいろんなパスを叩いてみてください。

大してファイルを追加していませんし、Gulpのプロジェクト構成は人それぞれだと思いますので、改変しやすいようあえてプラグインにはしていません。ご自由にカスタマイズしていただいて結構です。

37
32
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
37
32