Scala / Java 向けの
Play Framework と
さて、
モチベーション
前提と
Ruby on Railsでrails new
する
一方、
Play Framework が
そこで、sbt run
だけで
また、
ページで 実現する こと
この- TypeScript で
書いた コードが トランスパイルされて puclic/javascripts
ディレクトリ以下に配置される - テンプレートエンジン内で
書いた Tailwind CSS の クラスが コンパイルされて puclic/stylesheets
ディレクトリ以下に配置される sbt run
を実行すると TypeScript トランスパイラと Tailwind CSS の コンパイラが 起動する (コンソールから 別途立ち上げなくていい) - 画面を
リロードすると 通常の コンパイルに 加えて TypeScript と Tailwind CSS も コンパイルされ 変更が 反映される (ホットリロード対応) - Cache Busting の
ために 生成した 静的ファイルに フィンガープリントを 付与する sbt dist
でビルドした バイナリファイルに TypeScript と Tailwind CSS の コンパイルで 生成された ファイルを 同梱する
開発環境
- Play Framework: 3.0.1
- Java: 21.0.1
- Scala: 3.3.1
- sbt: 1.9.6
- Vite: 5.1.1
- TypeScript: 5.3.3
実装できないか
sbt-web でこちらのtsconfig.json
結局public
ディレクトリ以下に
ディレクトリ構造
ディレクトリ構造は
.|-- app| |-- controllers| | `-- HomeController.java| `-- views| |-- index.scala.html| `-- main.scala.html|-- build.sbt|-- conf| |-- application.conf| `-- routes|-- frontend| |-- package.json| |-- src| | |-- entries| | | `-- main.ts| | `-- styles| | `-- input.css| |-- tailwind.config.ts| |-- tsconfig.json| `-- vite.config.mts`-- public |-- javascripts | `-- main.js `-- stylesheets `-- main.css
フロントエンドのfrontend
以下で
frontend/src/entries/main.ts
がpublic/javascripts
以下に
Tailwind CSS にpublic/main.css
に
環境構築
Vite の何は
pnpm add -D vite typescript @types/node
次に
import { resolve } from 'path';import { defineConfig } from 'vite';
export default defineConfig({ build: { emptyOutDir: true, lib: { entry: [resolve(__dirname, 'src', 'entries', 'main.ts')], formats: ['es'], fileName: (_, entryName) => `${entryName}.js`, }, outDir: resolve(__dirname, '..', 'public', 'javascripts'), },});
今回はlib
を
ここでpath.resolve()
メソッドを
ここではfrontend/src/entries/main.ts
、public/javascripts
と
実際に
エントリポイントに
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'.split('');
/** * Calculates the shifted index based on the current index and shift amount. * * @param currentIndex - The current index. * @param shiftAmount - The amount to shift the index. * @returns The shifted index. */const getShiftedIndex = (currentIndex: number, shiftAmount: number): number => { let newIndex = currentIndex + shiftAmount; if (newIndex > 25) newIndex = newIndex - 26; if (newIndex < 0) newIndex = 26 + newIndex; return newIndex;};
/** * Converts a string using the Caesar cipher algorithm. * * @returns The converted string. */window.convertCaesarCipher = () => { const inputString = prompt('Enter a string to be shifted') || ''; if (!inputString) return alert('You must enter a string to be shifted');
let shiftAmount = parseInt(prompt('Enter a shift amount') || ''); if (isNaN(shiftAmount)) return alert('You must enter a valid shift amount');
shiftAmount = shiftAmount % 26; const lowerCaseString = inputString.toLowerCase(); let shiftedString = '';
Array.from(lowerCaseString).forEach((currentLetter, i) => { if (currentLetter === ' ') { shiftedString += currentLetter; return; }
const currentIndex = ALPHABET.indexOf(currentLetter); const shiftedIndex = getShiftedIndex(currentIndex, shiftAmount);
if (inputString[i] === inputString[i].toUpperCase()) { shiftedString += ALPHABET[shiftedIndex].toUpperCase(); } else shiftedString += ALPHABET[shiftedIndex]; });
return alert(shiftedString);};
毎回package.json
に
{ "name": "play-framework-java-playground", "private": true, "scripts": { "vite:dev": "vite build --watch", "vite:build": "vite build" }}
vite:dev
はvite:build
はvite:build
を
すると、public/javascripts/main.js
const i = "abcdefghijklmnopqrstuvwxyz".split(""), d = (e, n) => { let t = e + n; return t > 25 && (t = t - 26), t < 0 && (t = 26 + t), t;};window.convertCaesarCipher = () => { const e = prompt("Enter a string to be shifted") || ""; if (!e) return alert("You must enter a string to be shifted"); let n = parseInt(prompt("Enter a shift amount") || ""); if (isNaN(n)) return alert("You must enter a valid shift amount"); n = n % 26; const t = e.toLowerCase(); let r = ""; return Array.from(t).forEach((s, o) => { if (s === " ") { r += s; return; } const f = i.indexOf(s), a = d(f, n); e[o] === e[o].toUpperCase() ? r += i[a].toUpperCase() : r += i[a]; }), alert(r);};
問題なく
操作
public リソースの次に
GET / controllers.HomeController.index()
package controllers;
import play.mvc.Controller;import play.mvc.Http.Request;import play.mvc.Result;
public class HomeController extends Controller { public Result index() { return ok(views.html.index.render()); }}
@(title: String)(content: Html)
<!DOCTYPE html><html lang="ja"> <head> <title>@title</title> </head> <body> @content </body></html>
@()
@main("Welcome to Play") { <h1>Welcome to Play!</h1>}
上記のhttp://localhost:9000
に
public
ディレクトリにroutes
ファイルに
GET / controllers.HomeController.index()
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
versioned
と
上記でpuclic
をpuclic
ディレクトリ以下の
@(title: String)(content: Html)
<!DOCTYPE html><html lang="ja"> <head> <title>@title</title> <script type="module" src="@routes.Assets.versioned("javascripts/main.js")" async></script> </head> <body> @content </body></html>
早速 JavaScript を
@()
@main("Welcome to Play") { <h1>Welcome to Play!</h1> <button onclick="convertCaesarCipher()"> Convert to Caesar cipher </button>}
ボタンを
環境構築
Tailwind CSS のTypeScript の
まずは
pnpm add -D tailwindcss
次に
import type { Config } from 'tailwindcss';
export default { content: ['../app/views/*.scala.html'], theme: { extend: {}, }, plugins: [],} satisfies Config;
ここでapp/views
以下の
設定ファイルの
@tailwind base;@tailwind components;@tailwind utilities;
続いて
{ "name": "play-framework-java-playground", "private": true, "scripts": { "vite:dev": "vite build --watch", "vite:build": "vite build", "tailwind:dev": "tailwindcss -i src/styles/input.css -o ../public/stylesheets/main.css --watch=always", "tailwind:build": "tailwindcss -i src/styles/input.css -o ../public/stylesheets/main.css --minify" }}
最後に
@(title: String)(content: Html)
<!DOCTYPE html><html lang="ja"> <head> <title>@title</title> <link rel="stylesheet" media="screen" href="@routes.Assets.versioned("stylesheets/main.css")"> <script type="module" src="@routes.Assets.versioned("javascripts/main.js")" async></script> </head> <body> @content </body></html>
tailwind:build
を
@()
@main("Welcome to Play") { <h1 class="text-xl text-neutral-500">Welcome to Play!</h1> <button class="rounded-lg border-amber-200 bg-amber-200 p-2 text-amber-900 transition-colors hover:bg-amber-300" onclick="convertCaesarCipher()"> Convert to Caesar cipher </button>}
tailwind:build
をpublic/stylesheets/main.css
に
/*! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.rounded-lg{border-radius:.5rem}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity))}.bg-amber-200{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity))}.p-2{padding:.5rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-amber-900{--tw-text-opacity:1;color:rgb(120 53 15/var(--tw-text-opacity))}.text-neutral-500{--tw-text-opacity:1;color:rgb(115 115 115/var(--tw-text-opacity))}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:bg-amber-300:hover{--tw-bg-opacity:1;background-color:rgb(252 211 77/var(--tw-bg-opacity))}
http://localhost:9000
を
使って sbt run を 拡張する
PlayRunHook をPlayRunHook を
project/PlayDevRunHook.scala
に
import java.io.PrintWriterimport play.sbt.PlayRunHookimport sbt.*
import scala.io.Sourceimport scala.language.reflectiveCallsimport scala.sys.process.Process
object PlayDevRunHook {
object FrontendCommands { val install = "pnpm install" val viteDev = "pnpm vite:dev" val tailwindDev = "pnpm tailwind:dev" }
object Shell { /** * Execute a command in the shell * * @param cmd command to execute * @param cwd working directory * @param envs environment variables * @return exit code */ def execute(cmd: String, cwd: File, envs: (String, String)*): Int = { Process(cmd, cwd, envs *).! }
/** * Invoke a command in the shell * * @param cmd command to execute * @param cwd working directory * @param envs environment variables * @return process */ def invoke(cmd: String, cwd: File, envs: (String, String)*): Process = { Process(cmd, cwd, envs *).run } }
/** * Create a PlayRunHook to watch frontend changes * * @param base base directory * @return PlayRunHook */ def apply(base: File): PlayRunHook = {
val frontendBase = base / "frontend" val packageJsonPath = frontendBase / "package.json"
val frontEndTarget = base / "target" / "frontend" val packageJsonHashPath = frontEndTarget / "package.json.hash"
object FrontendBuildProcess extends PlayRunHook { var processes: List[Process] = Nil
/** * Invoked before the Play application starts */ override def beforeStarted(): Unit = { def using[A <: { def close(): Unit }, B](resource: A)(file: A => B): B = try { file(resource) } finally { resource.close() }
println("Hook to Play Framework dev run -- beforeStarted")
val currPackageJsonHash = using(Source.fromFile(packageJsonPath)) { source => source.getLines().mkString.hashCode().toString }
val oldPackageJsonHash = getStoredPackageJsonHash
if (!oldPackageJsonHash.contains(currPackageJsonHash)) { println(s"Found new/changed package.json. Run '${FrontendCommands.install}'...")
Shell.execute(FrontendCommands.install, frontendBase)
updateStoredPackageJsonHash(currPackageJsonHash) } }
/** * Invoked after the Play application has been started */ override def afterStarted(): Unit = { println(s"> Watching frontend changes in $frontendBase") processes = List( Shell.invoke(FrontendCommands.viteDev, frontendBase), Shell.invoke(FrontendCommands.tailwindDev, frontendBase) ) }
/** * Invoked after the Play application has been stopped */ override def afterStopped(): Unit = { processes.foreach(_.destroy()) processes = Nil }
/** * Get the stored package.json hash * * @return hash */ private def getStoredPackageJsonHash: Option[String] = { def using[A <: { def close(): Unit }, B](resource: A)(file: A => B): B = try { file(resource) } finally { resource.close() }
if (packageJsonHashPath.exists()) { using(Source.fromFile(packageJsonHashPath)) { source => Some(source.getLines().mkString) } } else { None } }
/** * Update the stored package.json hash * * @param hash hash */ private def updateStoredPackageJsonHash(hash: String): Unit = { val dir = frontEndTarget
if (!dir.exists) dir.mkdirs
val pw = new PrintWriter(packageJsonHashPath)
try { pw.write(hash) } finally { pw.close() } } }
FrontendBuildProcess }}
次にbuild.sbt
に
import scala.sys.process.Process
name := """play-framework-java-playground"""organization := "me.kkhys"maintainer := "hi@kkhys.me"
ThisBuild / scalaVersion := "3.3.1"
ThisBuild / version := "1.0.0-SNAPSHOT"
lazy val root = (project in file(".")) .enablePlugins(PlayJava) .settings( libraryDependencies ++= Seq( guice, "com.google.inject" % "guice" % "5.1.0", "com.google.inject.extensions" % "guice-assistedinject" % "5.1.0" ) )
PlayKeys.playRunHooks += baseDirectory.map(PlayDevRunHook.apply).value
sbt run
を
静的ファイルを 同梱する
dist 時にsbt run
をsbt dist
で
こちらはbuild.sbt
に
import scala.sys.process.Process
...
lazy val frontEndBuild = taskKey[Unit]("Execute frontend build command")
val frontendPath = "frontend" val frontEndFile = file(frontendPath)
frontEndBuild := { println(Process("pnpm install", frontEndFile).!!) println(Process("pnpm vite:build", frontEndFile).!!) println(Process("pnpm tailwind:build", frontEndFile).!!) }
dist := (dist dependsOn frontEndBuild).value stage := (stage dependsOn dist).value
静的ファイルに 付与する
フィンガープリントを最後に
フィンガープリントを
puclic
ディレクトリ以下の
addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.1") addSbtPlugin("com.github.sbt" % "sbt-digest" % "2.0.0")
ThisBuild / libraryDependencySchemes += "org.scala-lang.modules" %% "scala-xml" % VersionScheme.Always
次にbuild.sbt
に
...
lazy val root = (project in file(".")) .enablePlugins(PlayJava) .enablePlugins(SbtWeb) .settings( libraryDependencies ++= Seq( guice, "com.google.inject" % "guice" % "5.1.0", "com.google.inject.extensions" % "guice-assistedinject" % "5.1.0" ) )
Assets / pipelineStages := Seq(digest)
Assets / pipelineStages
の
開発環境で
/assets/stylesheets/b993b456bf80af0695501c8acd69ab8c-main.css
何も
/assets/stylesheets/58c0961b8f2d1f3b0837dd4fe7c8c86b-main.css
ごに
さいTypeScript が
と
テンプレートエンジンの
もし、
もっとも、