Enjoy SFV More の技術解説 2020.08.03 更新
- 概要
- 更新履歴
- HTML
- CSS
- JavaScript
- TypeScript
- Web Components
- PWA
- AMP
- i18n
- Eleventy
- webpack
- html-webpack-plugin
- Express
- GitHub Actions
- Fireabase
- Vercel
- Jest
- eslint
- prettier
- 今後
- まとめ
概要
Enjoy SFV More という、ストリートファイターV をより楽しむためのサービスを開発しました👊
日本版
英語版
https://twitter.com/EnjoySFVMore
サイトの特徴は
- 静的なページ(
Firebase Hosting
)と動的なページ(Firebase Functions
)で構成 Eleventy
を利用し静的なページを生成- 動的なページや API は
Node.js
+express
を利用 - フロントは
Web Components
を利用 - 全て
TypeScript
で記述 React
,Vue
,jQuery
といったフレームワークやライブラリは利用をしていないPWA
を導入- 一部ページは
AMP
対応
といったところです。
この記事では利用している技術について解説していきたいと思います(変更などがあれば本記事を随時更新していきます)
ディレクトリ
まずはじめにディレクトリ構成です。
firebase cli
で作りました。
更新履歴
- 2020.08.03 Eleventy 記事を追加
HTML
Web Components
で書いている以外特別何かしているということはあまりないです。
CSS
CSS
のライブラリなどや SCSS
などは導入しておらず、普通に書いています。
display:flex
が好きなので、どんなレイアウトでも display:flex
で何とかしています🤔
ShadowDOM
を利用しているので、FLOCSS
のような書き方をしていますが、結構適当な命名ルールで書いてます😇
JavaScript
できるだけ標準の機能を使おうと思っているので、axios
などといったライブラリなどは導入していません(Fetch API
使ってリクエスト投げたりしています)
package.json
Firebase Hosting
側の package.json
開発時は npm run serve
と npm run watch
を実行して開発しています。
スマホでデバッグしたい時は npm run dev-serve
で ngrok
を起動させています。
{ "scripts": { "build": "NODE_ENV=production webpack --mode production", "watch": "NODE_ENV=development webpack --mode development --watch", "serve": "firebase emulators:start --only functions,hosting", "dev-serve": "ngrok http 5000", "test": "jest" }, "devDependencies": { "@types/jest": "^24.9.1", "@typescript-eslint/eslint-plugin": "^2.31.0", "@typescript-eslint/parser": "^2.31.0", "eslint": "^6.8.0", "eslint-config-airbnb-base": "^14.1.0", "eslint-config-prettier": "^6.11.0", "eslint-loader": "^3.0.4", "eslint-plugin-prettier": "^3.1.3", "html-webpack-plugin": "^3.2.0", "jest": "^24.9.0", "prettier": "^1.19.1", "script-ext-html-webpack-plugin": "^2.1.4", "ts-jest": "^24.3.0", "ts-loader": "^6.2.2", "typescript": "^3.8.3", "webpack": "^4.43.0", "webpack-cli": "^3.3.11" }, "dependencies": { "@types/google.analytics": "0.0.40", "@types/gtag.js": "0.0.3", "firebase": "^7.14.2", "street-fighter-v-data": "github:tiwuofficial/street-fighter-v-data#master" } }
Firebase Functions
側の package.json
開発時は npm run watch
で typescript
のコンパイルだけ動かしています(なぜか firebase cli
で作られる package.json
に無い🤔 )
{ "scripts": { "lint": "tslint --project tsconfig.json", "build": "tsc", "watch": "tsc --watch", "serve": "npm run build && firebase serve --only functions", "shell": "npm run build && firebase functions:shell", "start": "npm run shell", "deploy": "firebase deploy --only functions", "logs": "firebase functions:log" }, "engines": { "node": "10" }, "dependencies": { "canvas": "^2.6.1", "ejs": "^3.1.2", "express": "^4.17.1", "firebase-admin": "^8.6.0", "firebase-functions": "^3.3.0", "googleapis": "^47.0.0", "sitemap": "^5.1.0", "street-fighter-v-data": "github:tiwuofficial/street-fighter-v-data#master", "zlib": "^1.0.5" }, "devDependencies": { "@types/node": "^13.13.5", "firebase-functions-test": "^0.1.6", "tslint": "^5.12.0", "typescript": "^3.2.2" } }
Firebase Hosting
でも Firebase Functions
でも同じクラスなどを扱うために street-fighter-v-data
リポジトリを読み込んでいます。
他に良い方法があるのかもしれないのですが、浮かばなかったのでこのような構成にしています。
一度 npm i
でインストールした後、リポジトリを更新して反映を取り込もうとして npm i
を実行しても取り込めないので、毎回 node_modules
からディレクトリを削除して npm i
を実行して取り込んでいます😇
良い方法知っている方いたら教えていただけると🙏
TypeScript
Firebase Hosting
, Firebase Functions
, street-fighter-v-data
など全て TypeScript
で書いています。
street-fighter-v-data
では Firebase Functions
で読み込むために JavaScript
にビルドしています。
型定義ファイルも出力するために declaration
を true
にしています。
street-fighter-v-data
の tsconfig.json
{ "compilerOptions": { "outDir": "dist", "module": "commonjs", "noImplicitReturns": true, "noUnusedLocals": true, "sourceMap": true, "target": "es2017", "moduleResolution": "node", "declaration": true }, "compileOnSave": true, "exclude": [ "__tests__" ] }
Firebase Hosting
の tsconfig.json
{ "compilerOptions": { "sourceMap": true, "target": "ES6", "module": "es2015", "lib": [ "dom", "esnext", "webworker" ] }, "exclude": [ "functions" ] }
Firebase Functions
の tsconfig.json
{ "compilerOptions": { "module": "commonjs", "noImplicitReturns": true, "noUnusedLocals": true, "outDir": "lib", "sourceMap": true, "target": "es2017" }, "compileOnSave": true, "include": [ "src" ] }
まだそこまで使いこなせておらず、簡単な型の定義くらいしか書いていません。
唯一工夫したのは Service Worker
関連です。
Service Worker
関連の関数(skipWaiting
)の型を効かせるために webworker
を lib
に指定して、declare let self: ServiceWorkerGlobalScope;
を定義して型を効かせています。
declare let self: ServiceWorkerGlobalScope; self.addEventListener("install", async event => { self.skipWaiting(); });
Web Components
サイトのメインどころである Web Components
です。わからないことや困ったらよく下記の Google の記事を読んでいます。
LitElement
という Google
の Polymer
プロジェクトが作っている Web Components
ライブラリは利用していません。
ディレクトリ構成
ディレクトリ構成は web-components
ディレクトリ内に、component
(components
にすべきと書きながら気づきました😇 )、page
、 raw-components
としています。
今見るとキャメル、スネークごちゃまぜになっている 😇
raw-components
raw-components
は何も import
などしていない単体で成立する下記のような Web Components
を置いています。
class Button extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }).innerHTML = ` <style> :host { background-color: #888; padding: 10px 0; font-size: 15px; border: none; border-radius: 24px; display: flex; justify-content: center; align-items: center; margin: 0 auto; color: #fff; width: 100%; cursor: pointer; } </style> <slot></slot> `; } } customElements.define("enjoy-sfv-more-button", Button);
上記はシンプルな Web Components
で下記のようにタグで囲ったテキストが <slot></slot>
に展開されます。
<enjoy-sfv-more-button>もっとキャラを探す!</enjoy-sfv-more-button>
textarea
など入力を受け付ける Web Components
は ShadowDOM
でタグが隠されているので、独自でイベントを発火させています。
class Textarea extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }).innerHTML = ` <textarea id="textarea" cols="30" rows="5"></textarea> `; this.shadowRoot.getElementById("textarea").addEventListener("input", () => { const elm: HTMLTextAreaElement = this.shadowRoot.getElementById("textarea") as HTMLTextAreaElement; this.dispatchEvent( new CustomEvent("input", { detail: { value: elm.value } }) ); }); } } customElements.define("enjoy-sfv-more-textarea", Textarea);
イベントは他のタグと同様に addEventListener
で処理をしています。
this.shadowRoot.querySelector("enjoy-sfv-more-textarea").addEventListener("input", (e: CustomEvent) => { console.log(e.detail.value); });
componet
raw-components
を組み合わせたりほかコードを import
したりしている Web Components
を置いています。
キャラクター選択モーダルは1つの Web Components
として作成しています。
下記2画面で使われているキャラクター選択モーダルは同じ Web Components
が使われています。
https://enjoy-sfv-more.com/combos/create
https://enjoy-sfv-more.com/lounge/create
import { characters } from "characters"; class CharacterSelectModal extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }).innerHTML = ` <enjoy-sfv-more-modal> <enjoy-sfv-more-p class="title">キャラクターを選択してください</enjoy-sfv-more-p> <div id="list"></div> </enjoy-sfv-more-modal> `; const characterClick = (e): void => { this.shadowRoot.host.removeAttribute("open"); this.dispatchEvent( new CustomEvent("enjoy-sfv-more-character-select-modal-selected", { detail: { characterId: e.currentTarget.getAttribute("character-id") } }) ); }; characters.characterForEach(character => { const elm = document.createElement("enjoy-sfv-more-p"); elm.textContent = character.name; elm.setAttribute("character-id", character.id); elm.addEventListener("click", characterClick); this.shadowRoot.getElementById("list").appendChild(elm); }); } } customElements.define("enjoy-sfv-more-character-select-modal", CharacterSelectModal);
キャラクターが選択されたらモーダルを閉じて(上記からはコードを省いています)、enjoy-sfv-more-character-select-modal-selected
イベントを発火するようにしています。
this.shadowRoot.querySelector("enjoy-sfv-more-character-select-modal").addEventListener("enjoy-sfv-more-character-select-modal-selected", e => { console.log(e.detail.characterId); });
受け取り側はカスタムイベントから e.detail.characterId
で選択されたキャラクターのIDを取得ができます。
page
Enjoy SFV More は各画面下記のように、enjoy-sfv-more-body
タグを共通で記述し、子のタグに各画面に対応する Web Components
を記述しています。
<body> <enjoy-sfv-more-body> <enjoy-sfv-more-frames></enjoy-sfv-more-frames> </enjoy-sfv-more-body> </body>
<body> <enjoy-sfv-more-body top> <enjoy-sfv-more-top></enjoy-sfv-more-top> </enjoy-sfv-more-body> </body>
raw-components
や componet
ディレクトリにある Web Components
を利用して画面を作り上げます。
その他資料
PWA
PWA を利用してアプリのようにサイトを開発しています。
Install
Enjoy SFV More はインストールをしてホーム画面に追加することができます。
新しい機能をリリースしました!
— Enjoy SFV More (@EnjoySFVMore) 2020年4月4日
・インストール
右上のインストールボタンを押すとホーム画面にアプリとして追加されます!#ストVラウンジ募集 の検索やフレーム表・対策メモなどたくさん使ってください!https://t.co/fmuIDUnBks
#SFV #StreetFighterV #ストV #スト5 #ストリートファイター5 pic.twitter.com/eKDn9HZNd0
インストールできるようにするためには manifest.json
を置いて、空で Service Worker
の fetch
を登録すれば最小限で実現できます。
{ "name": "Enjoy SFV More", "short_name": "Enjoy SFV More", "icons": [{ "src": "/src/img/icon/logo-192x192.png", "sizes": "192x192", "type": "image/png" },{ "src": "/src/img/icon/logo-512x512.png", "sizes": "512x512", "type": "image/png" }], "start_url": "/", "display": "standalone", "background_color": "#3E4EB8", "theme_color": "#2F3BA2" }
self.addEventListener("fetch", e => {});
最小限の構成で実装するとインストールを促す mini-infobar
などの表示タイミングはブラウザに委ねられるので、ボタンクリックなど任意のタイミングでインストールできるように実装しています。
window.addEventListener("beforeinstallprompt", e => { e.preventDefault(); this.deferredPrompt = e; });
まずは、 beforeinstallprompt
イベントで mini-infobar
が表示されないように e.preventDefault();
を実行しイベントのオブジェクトを保持します。
document.getElementById('button').addEventListener('click', (e) => { this.deferredPrompt.prompt(); deferredPrompt.userChoice.then((choiceResult) => { if (choiceResult.outcome === 'accepted') { console.log('accepted'); } else { console.log('dismissed'); } }); });
用意したインストールボタンのクリックイベントなどで、保持していた beforeinstallprompt
イベントのオブジェクトの prompt
を実行することで mini-infobar
などが表示されます。
userChoice
を経由すればユーザーが許可したか拒否したか取得できます。
window.addEventListener('appinstalled', () => { console.log('install'); });
インストール数を取得したい場合は、appinstalled
がインストール時に発生するイベントなどでこちらを利用するのが正確に取れるかと思います。
その他資料
Cache
Cache API
+ Service Worker
を利用しキャッシュによる高速な表示を実装しています。
Cache API
は困ったら MDN の記事をよく読んでいます。
Enjoy SFV More では画像と HTML
, JavaScript
を分けて考えキャッシュしています。
import { CACHE_NAME } from "./env-sw-cache"; const ASSET_CACHE_NAME = "asset-v1"; const IMGS = [ "src/img/hero-logo.png", "src/img/icon/punch.svg" ]; const FILES_TO_CACHE = ["/", "/lounge/create"]; async function onInstall(): Promise<void> { self.skipWaiting(); const imgCache = await caches.open(ASSET_CACHE_NAME); await imgCache.addAll(IMGS); const cache = await caches.open(CACHE_NAME); return cache.addAll(FILES_TO_CACHE); } self.addEventListener("install", async event => { event.waitUntil(onInstall()); }); async function onActivate(): Promise<boolean[]> { const keys = await caches.keys(); return await Promise.all( keys.map(key => { if (key !== CACHE_NAME && key !== ASSET_CACHE_NAME) { return caches.delete(key); } }) ); } self.addEventListener("activate", event => { event.waitUntil(onActivate()); });
install
イベント内で、画像と HTML
をまるごとキャッシュに保存します。
activate
イベント内では古いキャッシュがあれば削除しています。
async function onFetch(request): Promise<Response> { const cache = await caches.open(CACHE_NAME); return caches.match(request).then(response => { return ( response || fetch(request).then(response => { if (response.type === "basic" && response.url.indexOf("src/js/index.js") >= 0) { cache.put(request, response.clone()); } return response; }) ); }); } self.addEventListener("fetch", event => { event.respondWith(onFetch(event.request)); });
fetch
イベントではキャッシュにあればキャッシュから返し、src/js/index.js
であればキャッシュに保存、それ以外はネットワークから取得するようにしています。
src/js/index.js?hash=154722cfd9ffa6be04b2
といったハッシュにしており、本来 install
内でハッシュ付き index.js
を読み込むべきなのですが、webpack
とうまく連携ができておらずこのような形に落ち着きました。
JavaScript の更新
JavaScript
のハッシュが更新された時、HTML
の script
タグのハッシュを変更しただけではキャッシュは更新されません。
HTML
をまるごとキャッシュしているため新しくビルドした HTML
をネットワーク経由で取得をしないためです。
旧ハッシュが書かれている HTML
を破棄するために、キャッシュ名の CACHE_NAME
を変更し、Serivce Worker
の更新処理を動かします。
Install
で新しい HTML
を新しいキャッシュ名に保存し、古いキャシュ名にある古い HTML
は Activate
で削除します。
現状キャッシュ名はビルド時に手動で変更をしているので、webpack
のハッシュ名などにして自動化をしたほうが良さそうです🤔
その他資料
Share API
コンボの詳細画面では Share API
を使ってネイティブアプリのようにシェアをすることができます。
新しい機能をリリースしました!
— Enjoy SFV More (@EnjoySFVMore) 2020年5月4日
・コンボの詳細画面
公開したコンボの詳細画面を作りました!さ
詳細画面ではSNSにシェアすることができます!
たくさんコンボを公開してシェアしてください!https://t.co/qMaT4sJvmP
#SFV #StreetFighterV #ストV #スト5 #ストリートファイター5 pic.twitter.com/lPsB6PQmzl
const newVariable: any = window.navigator; if (newVariable && newVariable.share) { this.shadowRoot.querySelector("button").classList.remove("is-hidden"); this.shadowRoot.querySelector("a").classList.add("is-hidden"); this.shadowRoot.querySelector("button").addEventListener("click", () => { newVariable .share({ title: "Enjoy SFV More", text: this.getAttribute("share-text"), url: document.location.href }) .then(() => console.log("Successful share")) .catch(error => console.log("Error sharing", error)); }); } else { this.shadowRoot.querySelector("a").setAttribute("href", encodeURI(`https://twitter.com/share?url=${document.location.href}&text=${this.getAttribute("share-text")}`)); }
実装はかなり簡単で share
が存在すれば利用して、なければツイッターで共有するようにしています。
その他資料
AMP
Enjoy SFV More のキャラクターのフレームのページは AMP
に対応しています。
【アップデート】
— Enjoy SFV More (@EnjoySFVMore) 2020年6月1日
Google 検索経由で表示したフレーム表で絞り込みができるようになりました!
今後もより使いやすくアップデートをしていくので、便利な機能などあれば気軽にリプライください!https://t.co/9qnED2IRNF#SF5 #SFV #ストV #スト5 #StreetFighterV pic.twitter.com/A6Rz2dzwO0
Enjoy SFV More では通常のページと AMP
のページを作っています。
https://enjoy-sfv-more.com/frames/dhalsim
https://enjoy-sfv-more.com/amp/frames/dhalsim
もともと通常のページは JavaScript
でタイトルやメタタグや中身をレンダリングしていたのですが、amphtml
タグは JavaScript
でレンダリングすると認識しないので、Express
で SSR
する方針に途中で変更しました。
並び替えや絞り込みを実現させるためにいろいろな AMP
のタグを利用しています。
モーダル表示
まず、並び替えや絞り込みを選択するモーダルの表示には、amp-lightbox
を利用しています。
<div on="tap:sort-lightbox" role="button" tabindex="0"> <p>フレームの並び替え</p> <p [text]="sortText">デフォルト</p> </div> <amp-lightbox id="sort-lightbox" layout="nodisplay"> <div on="tap:sort-lightbox.close" role="button" tabindex="2"></div> <div> <p>並び替える条件を選択してください</p> <amp-selector layout="container" on="select: AMP.setState({ listHeight: <%= height %>, sortId: event.targetOption, sortText: frameSortsState[event.targetOption], defaultListClass: 'is-hidden', }), list.changeToLayoutContainer(), sort-lightbox.close "> <p option="0">デフォルト</p> <p option="1">発生の早い順</p> <p option="2">発生の遅い順</p> </amp-selector> </div> </amp-lightbox>
on="tap:sort-lightbox"
で id="sort-lightbox"
の amp-lightbox
が表示され、on="tap:sort-lightbox.close"
で非表示になります。
要素の選択
並び替えや絞り込みの選択は amp-selector
を利用しています。
並び替えのような1つしか選択できないものは、on="select: "
内で event.targetOption
経由で選択した要素の option
が取得できます。
フレームの並び替えは、 AMP.setState
を利用して選択した並び替えのID (sortId: event.targetOption
)やテキスト(sortText: frameSortsState[event.targetOption]
)を変数に格納しています。
絞り込みのような複数選択できるものは、multiple
属性を付与します。
<amp-selector layout="container" multiple on="select: AMP.setState({ selectedIds: event.selectedOptions }) "> <p option="1">通常技</p> <p option="2">ジャンプ技</p> <p option="3">特殊技</p> </amp-selector>
選択された要素の option
はon="select: "
内で、event.selectedOptions
経由で配列で取得できます。
フレームの絞り込みではAMP.setState
を利用して選択された絞り込みのID(selectedIds: event.selectedOptions
)を変数に格納しています。
変数の利用
並び替えや絞り込みの選択時 amp-bind
を利用して選択した値を AMP.setState
を実行し変数に格納しています。
並び替え
<amp-state id="frameSortsState"> <script type="application/json"> { "0":"デフォルト", "1":"発生の早い順", "2":"発生の遅い順" } </script> </amp-state> <p [text]="sortText">デフォルト</p> <amp-selector layout="container" on="select: AMP.setState({ sortId: event.targetOption, sortText: frameSortsState[event.targetOption], }), "> <p option="0">デフォルト</p> <p option="1">発生の早い順</p> <p option="2">発生の遅い順</p> </amp-selector>
amp-state
を利用して、並び替えのオブジェクトを frameSortsState
変数に格納します。
並び替え「発生の早い順」が選択されたら sortText
変数には frameSortsState[1]
の値(発生の早い順)が格納され、[text]="sortText"
により「デフォルト」と表示している p
は「発生の早い順」が表示されます。
絞り込み
<amp-state id="frameFilterState"> <script type="application/json"> { "1":"通常技", "2":"ジャンプ技", "3":"特殊技" } </script> </amp-state> <p [text]="filterIds.map(id => frameFilterState[id]).join(', ') || 'なし'">なし</p> <amp-selector layout="container" multiple on="select: AMP.setState({ selectedIds: event.selectedOptions }) "> <p option="1">通常技</p> <p option="2">ジャンプ技</p> <p option="3">特殊技</p> </amp-selector> <p class="button" role="button" tabindex="4" on="tap:AMP.setState({ filterIds: selectedIds, }) ">検索する</p>
amp-state
を利用して、絞り込みのオブジェクトを frameFilterState
変数に格納します。
絞り込みを複数選択し検索ボタンをクリックすると、filterIds
に選択された絞り込みの ID
が格納され、 filterIds.map(id => frameFilterState[id]).join(', ')
により選択した絞り込みが表示されます。
フレーム一覧の表示
フレーム一覧は、初期表示と並び替え or 絞り込み による検索で表示方法が違います。
初期表示は AMP Cache
による高速な表示のため SSR
して表示させています。
並び替え or 絞り込み による検索では amp-list
を利用して表示させています。
<amp-list id="list" [src]="'/api/frames?characterId=<%= characterId %>&sortId=' + sortId + '&filterIds=' + filterIds" height="0" [height]="listHeight" width="auto"> <template type="amp-mustache"> <div class="p-frame-cassette"> <a href="{{ url }}" class="name">{{ name }}</a> </div> </template> </amp-list>
amp-list
は [src]
にフレーム一覧のデータを返す API
の URL
を定義します。
{ "items": [ { "url":"https://enjoy-sfv-more.com/frames/dhalsim/24", "name":"ヨガロケット" }, { "url":"https://enjoy-sfv-more.com/frames/dhalsim/25", "name":"ヨガフープ" } ] }
API
は上記のような構成でデータを返します。items
は amp-list
のデフォルトなため構成を変更したい場合は、items
属性を利用して変更ができます。
繰り返されるフレームは<template type="amp-mustache">
を利用して {{ url }}
、 {{ name }}
といったように表示させます。
初期表示では SSR
したフレーム一覧を表示するため height="0"
により amp-list
のフレーム一覧は非表示にしています。
<p class="button" role="button" tabindex="4" on="tap:AMP.setState({ listHeight: 100, filterIds: selectedIds, defaultListClass: 'is-hidden' }), list.changeToLayoutContainer(), filter-lightbox.close ">検索する</p>
並び替えや絞り込みで sortId
や filterIds
に変更があった場合、amp-list
は API
からデータを取得しフレーム一覧を表示します。
このとき、 SSR
しているフレーム一覧は defaultListClass: 'is-hidden'
によって非表示に、amp-list
のフレーム一覧は listHeight: 100
により一旦 100px になった後、list.changeToLayoutContainer()
によりフレーム一覧の要素がちょうど表示される高さに調整されます。
i18n
Enjoy SFV More は英語版があります。
下記を参考に、/en
配下にページを作り、<link rel="alternate" hreflang="ja" href="https://enjoy-sfv-more.com/">
, <link rel="alternate" hreflang="en" href="https://enjoy-sfv-more.com/en">
といったタグを仕込んでいます。
Web Components
は可能な限り使い回しをしていて、${this.hasAttribute("lang-en") ? "Damage" : "ダメージ"}
といったように、属性を元に表示するテキストを判定しています。
class ComboListLinkItem extends HTMLElement { constructor() { super(); } connectedCallback(): void { this.attachShadow({ mode: "open" }).innerHTML = ` <a href='' class="js-link"> <enjoy-sfv-more-p id="title">${this.getAttribute("title")}</enjoy-sfv-more-p> <enjoy-sfv-more-p class="character-title">${this.hasAttribute("lang-en") ? "Character" : "キャラクター"} : ${this.getAttribute("character-name")}</enjoy-sfv-more-p> <div class="info"> <enjoy-sfv-more-p>${this.hasAttribute("lang-en") ? "Damage" : "ダメージ"} : ${this.getAttribute("damage")}</enjoy-sfv-more-p> <enjoy-sfv-more-p>${this.hasAttribute("lang-en") ? "Stun" : "スタン"} : ${this.getAttribute("stun")}</enjoy-sfv-more-p> </div> <enjoy-sfv-more-p class="combo-title">${this.hasAttribute("lang-en") ? "Combo" : "コンボ"}</enjoy-sfv-more-p> <div id="combo"></div> </a> `; } } customElements.define("enjoy-sfv-more-combo-list-link-item", ComboListLinkItem);
Eleventy
フレームのページは Express
による SSR
から Eleventy
を使った静的サイトジェネレート(言わない?)にリファクタリングしました。
詳しくは下記記事をご覧ください🙇♂️
webpack
教科書どおりに typescript
をコンパイルしています。
const path = require("path"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const ScriptExtHtmlWebpackPlugin = require("script-ext-html-webpack-plugin"); module.exports = [ { entry: "./src/js/index.ts", module: { rules: [ { enforce: "pre", test: /\.ts$/, exclude: /node_modules/, use: { loader: "eslint-loader", options: { fix: true } } }, { test: /\.ts$/, use: "ts-loader" } ] }, resolve: { extensions: [".ts"] }, plugins: [ new HtmlWebpackPlugin({ template: "./src/html/lounge/index.html", filename: "lounge/index.html" }), new ScriptExtHtmlWebpackPlugin({ defaultAttribute: "async" }) ], output: { filename: "src/js/index.js?hash=[contenthash]", path: path.resolve(__dirname, "public"), publicPath: "/" } }, { entry: "./src/js/en/index.ts", module: { rules: [ { enforce: "pre", test: /\.ts$/, exclude: /node_modules/, use: { loader: "eslint-loader", options: { fix: true } } }, { test: /\.ts$/, use: "ts-loader" } ] }, resolve: { extensions: [".ts"] }, plugins: [ new HtmlWebpackPlugin({ template: "./src/html/en/index.html", filename: "en/index.html" }), new ScriptExtHtmlWebpackPlugin({ defaultAttribute: "async" }) ], output: { filename: "src/js/en/index.js?hash=[contenthash]", path: path.resolve(__dirname, "public"), publicPath: "/" } }, { entry: "./src/js/sw.ts", module: { rules: [ { enforce: "pre", test: /\.ts$/, exclude: /node_modules/, use: { loader: "eslint-loader", options: { fix: true } } }, { test: /\.ts$/, use: "ts-loader" } ] }, resolve: { extensions: [".ts"] }, output: { filename: "sw.js", path: path.resolve(__dirname, "public") } } ];
html-webpack-plugin
html-webpack-plugin
を利用して webpack
が吐き出すハッシュ付きの JavaScript
ファイルを HTML
に埋め込んでいます。
script-ext-html-webpack-plugin
埋め込まれる script
タグに async
が付かないので、script-ext-html-webpack-plugin
を利用して付与しています。
Express
SSR
したり、 API
開発のために Express
を利用しています。
特別な何かはしていないですが、OGP
自動生成を下記を参考に実装しました。
FirebaseCloudFunctionsとcanvasだけで動的にOGP画像を生成し2020年を生き延びよう - Qiita
FirebaseCloudFunctionsとcanvasだけで動的にOGP画像を生成し2020年を生き延びよう - Qiita
作成処理
const splitByMeasureWidth = (str: string, maxWidth: number, context: { measureText: CallableFunction }): Array<string> => { // サロゲートペアを考慮した文字分割 const chars = Array.from(str); let line = ""; const lines = []; for (const char of chars) { if (maxWidth <= context.measureText(line + char).width) { lines.push(line); line = char; } else { line += char; } } lines.push(line); return lines; }; const canvasData = { width: 1200, height: 630, padding: 60, titleMargin: 40, bodyMargin: 20, comboMargin: 20, lineWidth: (): number => { return canvasData.width - canvasData.padding * 2; } }; // タイトル部分の文字スタイル const titleFontStyle = { font: 'bold 73px "Noto Sans CJK JP"', lineHeight: 80, color: "#ffffff" }; // 本文部分の文字スタイル const bodyFontStyle = { font: '40px "Noto Sans CJK JP"', lineHeight: 60, color: "#ffffff" }; const createOgp = async (title: string, body: string, combo: string, memo: string, docId: string): Promise<void> => { const loaclTargetPath = "/tmp/target.png"; const targetPath = `ogps/${docId}.png`; // canvasの作成 const canvas = createCanvas(canvasData.width, canvasData.height); const ctx = canvas.getContext("2d"); // ----- // タイトル描画 // ----- // フォント設定 ctx.font = titleFontStyle.font; // 行数の割り出し const titleLines = splitByMeasureWidth(title, canvasData.lineWidth(), ctx); // タイトル分の高さ const titleHeight = titleLines.length * titleFontStyle.lineHeight; // ----- // 本文部分描画 // ----- // フォント設定 ctx.font = bodyFontStyle.font; // 行数の割り出し const comboLines = splitByMeasureWidth(combo, canvasData.lineWidth(), ctx); const comboHeight = comboLines.length * bodyFontStyle.lineHeight; const memoLines = splitByMeasureWidth(memo, canvasData.lineWidth(), ctx); // 背景画像の描画 const gradient = ctx.createLinearGradient(canvasData.width, 0, 0, canvasData.height); gradient.addColorStop(0, "#FF1763"); gradient.addColorStop(0.75, "#F98F78"); gradient.addColorStop(1, "#FFC778"); ctx.fillStyle = gradient; ctx.fillRect(0, 0, canvasData.width, canvasData.height); // 文字描画のベースラインを設定 ctx.textBaseline = "top"; // タイトルを描画 ctx.fillStyle = titleFontStyle.color; ctx.font = titleFontStyle.font; for (let index = 0; index < titleLines.length; index++) { ctx.fillText(titleLines[index], canvasData.padding, canvasData.padding + titleFontStyle.lineHeight * index); } // 本文を描画 ctx.fillStyle = bodyFontStyle.color; ctx.font = bodyFontStyle.font; // キャラクター : ${req.body.character}, ダメージ : ${req.body.damage}, スタン : ${req.body.stun} を描画 ctx.fillText(body, canvasData.padding, canvasData.padding + titleHeight + canvasData.titleMargin); // combo を描画 for (let index = 0; index < comboLines.length; index++) { ctx.fillText(comboLines[index], canvasData.padding, canvasData.padding + titleHeight + canvasData.titleMargin + canvasData.bodyMargin + bodyFontStyle.lineHeight * (index + 1)); } // memo を描画 for (let index = 0; index < memoLines.length; index++) { ctx.fillText( memoLines[index], canvasData.padding, canvasData.padding + titleHeight + comboHeight + canvasData.titleMargin + canvasData.bodyMargin + canvasData.comboMargin + bodyFontStyle.lineHeight * (index + 1) ); } // tmpディレクトリへの書き込み const buf = canvas.toBuffer(); fs.writeFileSync(loaclTargetPath, buf); // Storageにアップロード await bucket.upload(loaclTargetPath, { destination: targetPath }); // tmpファイルの削除 fs.unlinkSync(loaclTargetPath); };
取得処理
const checkIsExists = async (id: string): Promise<boolean> => { const filePath = `ogps/${id}.png`; const isExists = await bucket.file(filePath).exists(); return isExists[0]; }; const getUrl = async (id: string): Promise<string> => { const isExists = await checkIsExists(id); if (isExists) { const url = `ogps/${id}.png`; return `https://firebasestorage.googleapis.com/v0/b/enjoy-sfv-more.appspot.com/o/${encodeURIComponent(url)}?alt=media`; } return "https://enjoy-sfv-more.com/src/img/ogp.png"; };
EJS
SSR
には EJS
を利用しています。
<enjoy-sfv-more-combo-show combo-title="<%= combo.title %>" character-name="<%= character.name %>" character-en-name="<%= character.enName %>" damage="<%= combo.damage %>" stun="<%= combo.stun %>" memo="<%= combo.memo %>" memo="<%= combo.memo %>" frames="<%= JSON.stringify(frames) %>" user-name="<%= user.displayName %>" user-img="<%= user.photoURL %>" ></enjoy-sfv-more-combo-show>
レンダリングは Web Components
に任せているので属性を渡すだけやってます。
GitHub Actions
リリースは GitHub Actions
を利用しています。
name: Build and Deploy on: push: branches: - master jobs: build_and_deploy: name: Build&Deploy runs-on: ubuntu-latest steps: - name: Checkout Repo uses: actions/checkout@master - uses: actions/setup-node@v1 - name: Install Dependencies run: npm install - name: Install Functions Dependencies run: cd functions && npm install - name: Install firebase-tools run: npm install -g firebase-tools - name: Deploy to Firebase run: firebase deploy --token $FIREBASE_TOKEN env: FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
もともと GitHub Actions MarketPlace
にある Firebase
を利用していました。
しかし OGP
自動生成の際に Node
環境で canvas
を利用するよう修正したところ下記のようなエラーが出てリリースできなくなってしまいました。
Error loading shared library http://ld-linux-x86-64.so.2: No such file or directory (needed by /github/workspace/functions/node_modules/canvas/build/Release/libpixman-1.so.0)
原因としては GitHub Action for Firebase
の環境で canvas
のインストールに必要なライブラリたちがインストールされてなかったためです(たぶん)
なので、GitHub Action for Firebase
を利用をやめ、直接リリースできるよう修正しました💪
Fireabase
Firebase
気に入っているので、できるだけ完結するようにしています。
Hosting
静的リソースはキャッシュを強くするために Cache-Control
を設定しています。
rewrites
でルーティングを行っていて、glob パターン マッチングでゴリッと書いています。
{ "functions": { "predeploy": [ "npm --prefix \"$RESOURCE_DIR\" run lint", "npm --prefix \"$RESOURCE_DIR\" run build" ] }, "hosting": { "trailingSlash": false, "public": "public", "headers": [ { "source" : "**/*.@(jpg|jpeg|gif|png|svg|js)", "headers" : [ { "key" : "Cache-Control", "value" : "max-age=31536000,immutable" } ] } ], "ignore": [ "firebase.json", "**/.*", "**/node_modules/**" ], "predeploy": [ "npm run build" ], "rewrites": [ { "source": "/en", "destination": "/en/index.html" }, { "source": "/combos", "destination": "/combo/index.html" }, { "source": "/combos/my", "destination": "/combo/my.html" }, { "source": "/combos/my/*", "destination": "/combo/myShow.html" }, { "source": "/combos/create", "destination": "/combo/create.html" }, { "source": "/combos/*", "function": "app" }, { "source": "/combos/*/*", "function": "app" }, ] }, "emulators": { "functions": { "port": 5001 }, "hosting": { "port": 5000 } } }
Functions
SSR
や API
などで利用しています。
無料プランでは外部 API
は叩け無いので(ただし YouTube など同じ Google のサービスであれば叩ける)ので、Twitter を利用する API
は Vercel
を利用しています。
Database
募集やコンボの投稿は Database
に保存しています。
単純な投稿と検索しかないので今の所、特に困ってはないです🤔
Storage
OGP
の自動生成で Storage
に保存しています。
こちらも壁にぶつかるほど現状は使い込んでないです🙄
Vercel
Functions
の無料プランでは Google 以外の外部 API
を叩け無いので Vercel
を利用しています。
全然理解が浅いのですが Functions
のようなサービスなので、 Express
を利用して Twitter API を実行する API を実装しています。
Jest
まだ Web Components
のテストは書いていないのですが、独自クラスなどは書いています。
street-fighter-v-data/Lp.test.ts at master · tiwuofficial/street-fighter-v-data · GitHub
eslint
特別な何かはしていない(と思います)
{ "extends": [ "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "prettier/@typescript-eslint" ], "env": { "browser": true, "es6": true, "node": true }, "parserOptions": { "sourceType": "module" } }
prettier
幅の調整だけやっています🤔
{ "printWidth": 200, }
今後
まず、SSR
しているところは eleventy
を利用して SSG
しようと思います。
あとは、Web Components
だけの repository
を作って Storybook
などで管理していければと思ってます。
まとめ
Web Components
や PWA
や AMP
辺りに力を入れているので話のネタとしては盛り沢山な気がします👍
質問や間違っている点など何かあれば連絡ください🎉