【23日目】私のサービスは必要とされるか&見た目の部分【作曲の補助ツールを作るまでの日記】

プログラミング

2024年2月4日~2024年3月27日

  1. 本当にこのサービスは必要か
    1. 音楽の記事は書くハードルが高い
    2. Qiitaは何故成立するのか
      1. Qiita側の想い
      2. 書く側の想い
    3. 私のサービスはなぜ成立するのか?
    4. 音楽の記事を書くのは、プログラミングの記事を書くのに対してハードルが高い
    5. もし、需要が無かったら辞めるのか
  2. エスケープ処理関連の最終確認
    1. そもそも{{}}で囲っていれば問題ないのか
    2. エスケープ処理はなにをしているのか
    3. なぜ「”」「’」「<」「>」「&」なのか。
    4. {!! !!}を使う場合はどのような対策が要るのか
    5. HTMLPurifierは安全なのか
  3. CKEditorで利用する項目の最終選定
    1. 要るもの
    2. あるとうれしいもの
    3. 実装しないもの
  4. HTMLPurifier for Laravelを手探りで解説する
    1. インストール
    2. GitHubにある使い方
    3. 一番大事なHTMLPurifierのConfig
    4. configを簡単に説明する
    5. configをちゃんと説明する
      1. configのsettginsを詳しく理解する
      2. custom_definition、custom_elements、custom_attributesとは何か
      3. custom_definition、custom_elements、custom_attributesの定義方法
        1. custom_attributes
        2. custom_elements
        3. custom_definition
    6. HTMLPurifierでURIのスキームを指定
  5. TailwindCSSで記事専用CSSを適用する
    1. ViteでCSSとJSを適用する
    2. CSSファイル内でTailwindCSSを使う
    3. 文字がはみ出る問題
  6. 久しぶりにやるとブログとIsuueに助けられる
  7. 記事の編集画面と閲覧画面のCSS差について
    1. bootstrapにあるcontainerの意味と利点
    2. containerのデメリット
  8. サニタイズのテスト
  9. レート制限
    1. レート制限のカウント方法
    2. 全てのリクエストに制限をかける
  10. CKEditorの埋め込みembedについて
    1. 仕組み
      1. 対応メディア
      2. データの出力形式
    2. oEmbedのAPIを叩いてくれるサービス
    3. oEmbed対応メディアを拡張する
    4. デフォでoEmbed対応のメディアを削除する
    5. 日本語特有のCKEditorのSpotify問題
    6. ソースコードを弄ってintl-ja問題を解決する
    7. patch-packageを使い、node_modulesを修正する
    8. Youtubeの埋め込みに対応する例
      1. CKEditor側の処理を理解する
      2. そもそもoEmbedとは何だったのか
      3. SoundCloudにoEmbedの要求をしてみる
      4. iframeとは何なのか
      5. JavaScriptでoEmbedタグの処理を行う危険性
      6. HTMLPurifierでiframeを許可する
  11. Bootstrapをやめる
  12. Bootstrapをやめた後のCSS
    1. z-index
    2. Alpine.jsによるz-indexの動的な変化
    3. ペジネーションのカスタム
  13. デジタル庁のデザインシステムってなんだ
    1. デザインシステムを定めたい
  14. TailwindCSSを学びたい
    1. 本の1章まで読んで思ったTailwindCSSのメリット
    2. 2章を読んでおもったこと
    3. 4章の序盤を読み、ESlintとそのプラグインを知る
    4. CKEditorが公開しているCSSと、CSS衝突時の優先度について
    5. 4章のz-indexを見てからの扱い
      1. dialog要素でモーダルを書き直す
      2. JSを使わずに、特定のラジオボタンを選択したときに注意事項を表示する
    6. Arbitrary Values (px-[3px]などの)表記を使うのは慎重に
    7. 第9章を読んで、デザインシステムを考える
  15. オリジナルのデザインシステムをちょっとだけ定める
    1. デザイントークンで何を定めるか
    2. キーカラー、共通カラーを定める
    3. キーカラー
    4. 私にとって初音ミク、重音テトは何色か
      1. 色をTailwindCSSで定義する
      2. オリジナルのカラースケールを作ってみる
    5. 共通カラー
      1. 赤、緑、青、黄色の定義
    6. レスポンスのブレイクポイントについて
      1. 768pxの意味
      2. 超高解像度のデバイスはどうなるか
      3. 768pxをブレイクポイントにして問題なさそうか
      4. 768pxと1024pxの合計2つにブレイクポイントを持たせる
      5. TailwindCSSのブレイクポイントをカスタムする
    7. スペーシングについて
      1. w-104よりw-40が優先される – TailwindCSSの優先度
    8. 文字について
      1. 文字にかかわる要素
      2. 文字の大きさ
      3. remとは何か
      4. 行送り(line-height)とは何か
      5. 太さ
    9. フォント
      1. フォントファミリーとは
      2. Serifとは
      3. Noto Sans Japaneseとは何か
      4. Noto Sans Japaneseを採用すべきか
        1. 表示速度は遅くなるか
        2. Qiita,Zenn,Noteではどうしているか
        3. 総称フォント
        4. YakuHanJPs
        5. -apple-systemとBlinkMacSystemFont
    10. コントラスト比4.5:1や3:1が難しいので、メインカラースケールを作る
    11. デザインシステムをぱっと確認できるようにしようとしたけど……
  16. 編集ページを考え直す
    1. バルーン方式のCKEditorにしてみる
    2. CKEditorのツールバー位置を調整する
  17. アップロードしたファイル一覧画面もレスポンシブにする
    1. audioをJavaScriptで操作する
  18. JavaScriptをどこに書くか
    1. 一般的な話
    2. 私の考え
  19. 終わりに

本当にこのサービスは必要か

色々な人の開発の日記とかを見ていると、そもそもサービスを作り始める前に以下の2つを考慮する必要がありそう。めっちゃ今更ではあるんだけど。

  • 需要があるか
  • 法的ハードルは低いか

一応、「音楽の記事」に対して需要はあるんじゃないか? と思ってるんだけど、冷静に考えて「音楽の記事」を書くのって難しいなぁと思って。つまり、需要はあっても、供給できる人は少ないかもなぁという感じ。

本格的にサービス開発に移る前に日本Googleとかの検索ボリュームは計っていて、大体「DTM」という単語は「プログラミング」の1/10くらいのボリューム。「Python」と「Cubase」を比較しても1/10なので、大体そんな感じ。

だから、上手くいってもQiitaの1/10くらいのサービスになると思っている。

じゃあ、何が問題かなんだけど、「音楽の記事を書くハードルが高い」というところ。

音楽の記事は書くハードルが高い

  • 私が想定していたのは「記事」で、「日記」でも「メモ」でもない。
    • 何故なら、汎用性・再利用性がある情報を発信して欲しいから。
      • 音楽の汎用性=音楽理論なのでは
  • どう音楽を作るかみたいなところは人それぞれだよね
    • だからこそ、発信してほしい気持ちもある
    • 人それぞれ、十人十色だからこそ発信者が大事になってしまうかも
  • 記事、日記、メモは区別したい
    • 記事
      • 纏められたもの
      • 出来るだけ分かりやすく書いたもの
      • 裏付けがあるもの、理論的であるもの(自分がそう体験し思ったもおk)
      • 記事というのは論理的に、誰にもわかるように、整理して説明する努力が必要になる。
    • 日記
      • その人の思ったもの、考え
      • 時系列が伴う記録
    • メモ
      • 自分がわかればいい
      • 殴り書きでもいい
    • 日記・メモは他のサービスに任せるべきな気もする
  • 音楽はプログラミングと違ってそもそも音だし、共通の困難が生まれにくい気がする
    • 正解はいくつもあるので、情報そのものでなく、誰が情報を発信しているかが大事になりそう
    • プログラミングは汎用性が高い
    • DTMの記事は人依存が強いことに気づいた
    • Qiita記事は人依存が弱いことに気づいた
  • プログラミングは理解が多いが、音楽は理解が少ない……と今は思っている

Qiitaは何故成立するのか

そもそも「記事を書き、公開する」という行為は「知識の共有」になるわけだけど、知識の共有手段は別に記事じゃなくても良い。Twitter(X)でもいいし、個人ブログでも良い。なんなら、知識の共有はしなくても良い。

じゃあ、Qiitaでは何でわざわざQiita専用の記事を書く場所を設け、そこに書いてくれる人がいるのか。

Qiita側の想い

Qiita側の想いは簡単で、Qiita ヘルプに書いてある。

要約すると、Qiita立ち上げ当初はプログラミングの困りごとを解決する情報が少なかった。だから、Qiitaを立ち上げて「再利用性・汎用性の高い情報が集まる場」を作った。という話。

最初はQ&Aのサイトだったらしいね。

書く側の想い

他の人はわからないけど、私がこのブログを書く意味について。でも私のはQiitaの記事と違い、日記なので違うかもしれない。

  • 不特定多数に見られているモチベーション
    私の場合誰でも閲覧できる状態にしているので、全然知らん人にこのブログを見られているかもしれない。そんな気持ちがモチベーションになるし、しっかりしようとなる。
  • 頭の整理
    文章化することで、頭の整理になるし「ここ説明できないなぁ」=「深く理解できていない」なので、理解が深まる。
  • 知識の保存
    再現性のある問題であれば、自分の記事を見て解決することも多い。
    あと、後々見たとき面白いかなって思って。

私のサービスはなぜ成立するのか?

いや、わからんけどね。

  • 頭の整理
  • 知識の保存
  • 閲覧のされやすさ
  • 承認欲求
  • 音楽に対する違うアプローチでの貢献
  • 自分を売る場所

音楽の記事を書くのは、プログラミングの記事を書くのに対してハードルが高い

目指すべきところは記事共有じゃなく、知識共有なのでは。

もし、需要が無かったら辞めるのか

私はこのサービスを私の為に作っている。
音楽を続けていく上で知識の共有場所は欲しいし、自分だけがユーザーだったとしても成り立つサービスなので、私が音楽を続ける限り続ける。
これが私個人がこのサービスを作る強みだと思う。

もちろん、運用費がかかり過ぎるとかだったら別のアプローチを考える。

エスケープ処理関連の最終確認

サニタイズがあやふやなのでチェック。

そもそも{{}}で囲っていれば問題ないのか

Laravelの公式ドキュメントには{{}}について以下のように書いてある。

Blade’s {{ }} echo statements are automatically sent through PHP’s htmlspecialchars function to prevent XSS attacks.

https://laravel.com/docs/10.x/blade

つまり、{{}}で囲まれた値はPHPのhtmlspecialchars関数を通っている。

htmlspecialcharsを通っており、かつENT_QUOTESの指定がされていれば,,<,>,&がエスケープ処理の対象になり、これは安全と言われている。
ENT_QUOTESが指定されていないと’(シングルクォート)が対象とならず、XSSにつながる。

もちろんLaravelの{{}}は以下のように全てエスケープ処理に対応している。

<script>
{{ "\" ' < > &" }}
</script>

エスケープ処理はなにをしているのか

なんか、「&」が「&amp;」になったりするのは知ってるけどこれが何なのかわかってないのでさらっと理解。

エスケープ処理は、エスケープ対象の文字をそれに対応する文字実体参照に変えている。

文字実体参照は「&文字の名前;」で定義されており、「&」の名前はampなので「&amp;」とすることでHTMLでは「&」と表示される。
前この「&amp;」とかを文字化けと言ったけど、ちゃんと表示できてたんだね。

これは最終的な表示が「&」になっているだけで、HTMLの解析段階では「&amp;」となっているので意図しないコードが埋め込まれたりしないという感じ。

また、今回は関係ない話だけど数値文字参照もある。
こちらは「&Unicodeの文字コード;」で定義されている。
つまり、見かけ上は「&」=「&amp;」=「&#038;」といえる。

なぜ「”」「’」「<」「>」「&」なのか。

ここで疑問なのは、なぜエスケープする文字が「”」「’」「<」「>」「&」なのか。

これは単純にこれら5つがHTML解析で特別な意味を示す文字だから。

<>はタグの開始と終わりを意味するし、”‘は属性の区切りを示す。&は先に言った通り文字実体参照や数値文字参照に使われる。

つまり、これらの文字を挿入されると意図しないHTML解析になる可能性があるという感じ。

{!! !!}を使う場合はどのような対策が要るのか

私の場合、CKEditorでのエスケープ処理とHTMLPurifierによるエスケープ処理とサニタイズが行われている。

ここで注意したいのはCKEditorはフロントエンドでのエスケープ処理であるということ。つまり、CKEditorを通さないPOSTはそのままなんも処理されず保存される。
そのため、実質頼れるエスケープ処理はHTMLPurifierだけといえる。

HTMLPurifierは安全なのか

これ、めっちゃ難しいところで、正直100%安全とは言えない。
しかし、かなりの間使われているライブラリだし、多くの人がつかっているから”安全だろう”という感じ。
そんな曖昧な感じでいいのかとは思うんだけど、そんなこと言ってたらそもそもLaravelは安全なのか? PHPは安全なのか? みたいにキリがないのでどこかで割り切る必要はある。

もちろん、HTMLPurifier自体が安全でも使い方を間違えたらダメなので慎重に扱う。

CKEditorで利用する項目の最終選定

現在CKEditorでは以下のように文字に対しいろいろな装飾ができるようになっている。しかし、HTMLPurifierでそれに対応するタグを許可をしない限り表示されることはない。

実際に以下のように閲覧画面では取り消し線、文字の拡大、引用は適用されていない。

編集画面閲覧画面

ここで今一度何が必要で何が要らないかを考える。

ここで考えるべきは可変容易度、脆弱性、自由度の3つかなぁと思う。

  • 可変容易度
    編集項目を増やすのは簡単だが減らすのは難しい。その為最初は必要なものだけ最低限付けたい。
  • 脆弱性
    そのタグを使えることによって発生しうる脆弱性。正直考えてもわからん。複雑なほど脆弱性は発生しやすそうだよね。
  • 自由度
    文字の大きさを変えれるのは自由度が高くて魅力的だが、統一性は無くなるよね。
    ある程度制限したほうが統一感と使いやすさが上がるかなと思う。トレードオフってやつ?

つまり、これは絶対に要る! というものを選んで、その上で脆弱性とかを考えて実装すればいいかな。

要るもの

  • 段落と見出し
    記事の構造を作るうえで絶対に要る。
  • 太文字
    お手軽に強調できるので絶対に要る。
  • 取り消し線
    文章の修正時に使えるので要る。
  • リンク
    リンクはWebで最も大事なもの。リンクがあることで主張の正当性やわかりやすさが増加するので要る。
  • 箇条書きリスト
    箇条書きにすると整理しやすい+わかりやすいので要る。
  • 番号リスト
    上と同じ理由。
  • 画像の投稿
    DTM関連のことは画像で説明した方がわかりやすいことも多いので要る。
  • 音の投稿
    上と同じ理由。
  • 引用
    記事の信頼性、引用のしやすさが増加するので要る。
  • テーブル
    画像を横に並べたり、表を作りたいときに便利なので要る。
  • undo/redoのボタン
    ctrl+zやctrl+yでもできるが、知らない人の方が多いと思うので要る。

あるとうれしいもの

  • 検索して置換する
    記事を修正したいときに便利なので欲しい。
    バグりやすそうなので、そこらへん要検証。
  • 書式設定の削除
    文章をコピペするときctrl+shift+vでも書式無しでコピーできるが、知らない人も多いと思うので、欲しい。
  • ブロックの表示
    構造を見える化することで記事の質が上がりそう。もし要らなくなって消したくても影響無いのがGood。
    バグりやすそう。
  • 番犬
    もしクラッシュしてもデータを保存してくれるらしい。実装が面倒そうでなければ是非入れよう。
  • 単語と文字数
    今のところ記事の文字数制限は厳しくしてないが、厳しくするなら欲しい。

実装しないもの

  • 文字の大きさ変更
    見出しと混乱する。見出しを使ってほしい。強調するならstrongで良い。
  • テキストの配置(左揃え、中央揃え、右揃え)
    レイアウトをサイトで統一したほうが見やすいかなと思ってとりあえず付けない。
    必要であれば後から追加する
  • ハイライト(mark)
    強調したい場合はstrongがある。
    MDNによるとstrongと使い分けに関することが言及されているが、だとしても使う部分が限定的。
  • イタリック
    引用時は勝手につくし、あまり使わない装飾なので付けない。
    マークダウン形式なら良いが、ボタンで選択して装飾する方式なので需要が低いものは取りあえず外す方針で。
  • 下線
    強調はstrongがある。
    また、下線で使われる<u>タグは強調でなく綴りの通知等が主らしいので、とりあえず付けない。
  • 背景色
    背景色を指定することで文字が環境依存で見えなくなる可能性。
    強調するならstrongで良い。
    特定の背景色だけを許可するのが難しい。
  • 文字色
    上と同じ理由
  • メディアのURL
    わざわざつけなくてもURLを記事内に直で付けたときに勝手に展開されるのでそれで良い。

とりあえず、上にあげた「要るもの」と「あるとうれしいもの」を付けたCKEditorをbuildする。

何個か新しいCKEditorからの機能があったので、現在の最新であるCKEditorをver41.xにアップデートする。
アップデート方法は公式ドキュメントにある。私はpackage.jsonのバージョンを直接指定した。

とりあえず以下みたいにシンプルなツールバーになった。

これで使うタグをPurifier側で許可してあげればおk。

HTMLPurifier for Laravelを手探りで解説する

このHTMLPurifier for LaravelってGitHubにWikiもないし使い方の解説が無い。
なんなら日本語の解説ブログも無い。
その為、かなり手探りで理解を進めていた。

最近大まかに理解できたので一回ここにまとめる。

前提として

  • HTMLPurifierはHTMLを洗浄(Purifier)し悪意のあるコードやHTMLの文法チェックをしてくれるもの
  • HTMLPurifier for LaravelはHTMLPurifierをLaravelで手軽に使えるようにしたものなので、本家はHTMLPurifierである
  • その為、ドキュメントはHTMLPurifierのドキュメントを見ると良い
  • 手探りなので、間違っていたりミスリードしている可能性はある

インストール

インストール方法はHTMLPurifier for LaravelのGitHubに書いてある。

Laravel5.5以降の場合は
composer require mews/purifier
するだけ。

Laravel10も対応しているので安心うれしい。

GitHubにある使い方

GitHubに書いてあるけど一応。

use Mews\Purifier\Facades\Purifier;
で使えるようにし、コントローラーやミドルウェアで利用する。

以下のようにすると、デフォルトの設定で洗浄してくれる。
Purifier::clean(“洗浄したいHTML”);

以下のようにすると、指定した設定を適用できる。
この場合はtitlesという設定で洗浄している。
Purifier::clean(‘洗浄したいHTML’, ‘titles’);

以下のようにすると設定を上書きできる。
Purifier::clean(‘This is my H1 title’, array(‘Attr.EnableID’ => true));

一番大事なHTMLPurifierのConfig

Configが一番大事まである。

以下のコマンドでPurifierのconfigをconfig/purifier.phpで弄れるようになる。
php artisan vendor:publish –provider=”Mews\Purifier\PurifierServiceProvider”

以下が初期のconfigファイル

<?php
/**
* Ok, glad you are here
* first we get a config instance, and set the settings
* $config = HTMLPurifier_Config::createDefault();
* $config->set('Core.Encoding', $this->config->get('purifier.encoding'));
* $config->set('Cache.SerializerPath', $this->config->get('purifier.cachePath'));
* if ( ! $this->config->get('purifier.finalize')) {
*     $config->autoFinalize = false;
* }
* $config->loadArray($this->getConfig());
*
* You must NOT delete the default settings
* anything in settings should be compacted with params that needed to instance HTMLPurifier_Config.
*
* @link http://htmlpurifier.org/live/configdoc/plain.html
*/


return [
   'encoding'           => 'UTF-8',
   'finalize'           => true,
   'ignoreNonStrings'   => false,
   'cachePath'          => storage_path('app/purifier'),
   'cacheFileMode'      => 0755,
   'settings'      => [
       'default' => [
           'HTML.Doctype'             => 'HTML 4.01 Transitional',
           'HTML.Allowed'             => 'div,b,strong,i,em,u,a[href|title],ul,ol,li,p[style],br,span[style],img[width|height|alt|src]',
           'CSS.AllowedProperties'    => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align',
           'AutoFormat.AutoParagraph' => true,
           'AutoFormat.RemoveEmpty'   => true,
       ],
       'test'    => [
           'Attr.EnableID' => 'true',
       ],
       "youtube" => [
           "HTML.SafeIframe"      => 'true',
           "URI.SafeIframeRegexp" => "%^(http://|https://|//)(www.youtube.com/embed/|player.vimeo.com/video/)%",
       ],
       'custom_definition' => [
           'id'  => 'html5-definitions',
           'rev' => 1,
           'debug' => false,
           'elements' => [
               // http://developers.whatwg.org/sections.html
               ['section', 'Block', 'Flow', 'Common'],
               ['nav',     'Block', 'Flow', 'Common'],
               ['article', 'Block', 'Flow', 'Common'],
               ['aside',   'Block', 'Flow', 'Common'],
               ['header',  'Block', 'Flow', 'Common'],
               ['footer',  'Block', 'Flow', 'Common'],
           
            // Content model actually excludes several tags, not modelled here
               ['address', 'Block', 'Flow', 'Common'],
               ['hgroup', 'Block', 'Required: h1 | h2 | h3 | h4 | h5 | h6', 'Common'],
           
            // http://developers.whatwg.org/grouping-content.html
               ['figure', 'Block', 'Optional: (figcaption, Flow) | (Flow, figcaption) | Flow', 'Common'],
               ['figcaption', 'Inline', 'Flow', 'Common'],
           
            // http://developers.whatwg.org/the-video-element.html#the-video-element
               ['video', 'Block', 'Optional: (source, Flow) | (Flow, source) | Flow', 'Common', [
                   'src' => 'URI',
               'type' => 'Text',
               'width' => 'Length',
               'height' => 'Length',
               'poster' => 'URI',
               'preload' => 'Enum#auto,metadata,none',
               'controls' => 'Bool',
               ]],
               ['source', 'Block', 'Flow', 'Common', [
               'src' => 'URI',
               'type' => 'Text',
               ]],


            // http://developers.whatwg.org/text-level-semantics.html
               ['s',    'Inline', 'Inline', 'Common'],
               ['var',  'Inline', 'Inline', 'Common'],
               ['sub',  'Inline', 'Inline', 'Common'],
               ['sup',  'Inline', 'Inline', 'Common'],
               ['mark', 'Inline', 'Inline', 'Common'],
               ['wbr',  'Inline', 'Empty', 'Core'],
           
            // http://developers.whatwg.org/edits.html
               ['ins', 'Block', 'Flow', 'Common', ['cite' => 'URI', 'datetime' => 'CDATA']],
               ['del', 'Block', 'Flow', 'Common', ['cite' => 'URI', 'datetime' => 'CDATA']],
           ],
           'attributes' => [
               ['iframe', 'allowfullscreen', 'Bool'],
               ['table', 'height', 'Text'],
               ['td', 'border', 'Text'],
               ['th', 'border', 'Text'],
               ['tr', 'width', 'Text'],
               ['tr', 'height', 'Text'],
               ['tr', 'border', 'Text'],
           ],
       ],
       'custom_attributes' => [
           ['a', 'target', 'Enum#_blank,_self,_target,_top'],
       ],
       'custom_elements' => [
           ['u', 'Inline', 'Inline', 'Common'],
       ],
   ],
];

ここで主な設定を弄れるが、一番上のコメントにある通り

* You must NOT delete the default settings
* anything in settings should be compacted with params that needed to instance HTMLPurifier_Config.

デフォルトの設定は削除しちゃだめ。

デフォルトっていうのは
‘default’ => [
で定義されている設定のことだと思われる。

configを簡単に説明する

めっちゃ簡単に説明すると、

HTML.Allowedに許可したいタグ名と属性を入れればおk!
CSS.AllowedPropertiesに許可したいCSS名入れればおk!

ホワイトリスト形式だから、Allowedに入れないと許可されないよ!

以上。

正直ここだけ弄れば大体済むんだけど、audioタグとかを使いたい場合はちゃんと理解しないといけない。

configをちゃんと説明する

HTML.AllowedとCSS.AllowedPropertiesだけじゃ解決できないときはここを読んでみて。

configの配列で定義されているのは主に以下の6つ。

  1. encoding
    本家ドキュメントのこの部分が該当。
    configの「 ‘encoding’           => ‘UTF-8’,」の部分。

    とにかく、UTF-8でエンコードする分には問題なさそう。
  2. finalize
    本家ドキュメントのこの部分が該当。finalizeの説明についてはここが該当。
    configの「 ‘finalize’           => true,」の部分。

    これがtrueだと設定を後から変えられないようにできる。上書きできないってこと。
    falseだと設定を変えられる。

    設定はこのファイルで全て終わらせる予定なので、trueで。
  3. ignoreNonStrings
    本家ドキュメントにない、for Laravel特有の機能。
    configの「   ‘ignoreNonStrings’   => false,」の部分

    コードを探したら以下の部分が該当。
    vendor\mews\purifier\src\Purifier.php
//If $dirty is not an explicit string, bypass purification assuming configuration allows this
$ignoreNonStrings = $this->config->get('purifier.ignoreNonStrings', false);
$stringTest = is_string($dirty);
if($stringTest === false && $ignoreNonStrings === true) {
   return $dirty;
}


return $this->purifier->purify($dirty, $configObject);

つまりtrueにしていると、取得したHTMLがString型じゃない場合purifierせずに何もせずにreturnするようになる。

falseにしていると、一応purifierしてくれるっぽい。

  1. cachePath
    本家ドキュメントにない、for Laravel特有の機能。
    configの「’cachePath’          => storage_path(‘app/purifier’),」の部分。

    configのキャッシュを保存する場所の指定。
  2. cacheFileMode
    configの「’cacheFileMode’      => 0755,」の部分。

    キャッシュを保存するディレクトリのパーミッション。
    ちなみに、上2つは以下のコードで利用されている。
    vendor\mews\purifier\src\Purifier.php
private function checkCacheDirectory()
{
   $cachePath = $this->config->get('purifier.cachePath');


   if ($cachePath) {
       if (!$this->files->isDirectory($cachePath)) {
           $this->files->makeDirectory($cachePath, $this->config->get('purifier.cacheFileMode', 0755),true);
       }
   }
  1. settings
    一番大事でよく弄る部分。

    ここで許可したいHTMLタグや定義されていないタグを定義する。
    一番大事なので下で詳しく解説する。

configのsettginsを詳しく理解する

前述したとおり一番大事な部分だし、軽く使うならここのdefaultに定義されているHTML.Allowedとかを弄れば良い。

しかし、ちょっと深いところまで弄りたいならここをしっかり理解したほうが良い。

settings関連のコードはvendor\mews\purifier\src\Purifier.phpのgetConfigメソッドにある
該当部分を切り抜くと以下のようになる。

私の理解でコメントも付けておいた。

// configの新しいオブジェクトを作成
$configObject = HTMLPurifier_Config::createDefault();


// configの変更を許可するか否か。config/purifier.phpで定義されているものを利用している。
if (! $this->config->get('purifier.finalize')) {
   $configObject->autoFinalize = false;
}


// configの初期設定。config/purifier.phpで定義されているものを利用している。
$defaultConfig = [];
$defaultConfig['Core.Encoding'] = $this->config->get('purifier.encoding');
$defaultConfig['Cache.SerializerPath'] = $this->config->get('purifier.cachePath');
$defaultConfig['Cache.SerializerPermissions'] = $this->config->get('purifier.cacheFileMode', 0755);


// もし使うconfigが指定されていたらそれを。無指定ならdefaultを使う。
if (! $config) {
   $config = $this->config->get('purifier.settings.default');
} elseif (is_string($config)) {
   $config = $this->config->get('purifier.settings.' . $config);
}


// 指定されたconfigが配列じゃないなら配列に
if (! is_array($config)) {
   $config = [];
}


// configを初期設定したものとmerge。
$config = $defaultConfig + $config;


// 出来上がったconfigを$configObjectにロード
$configObject->loadArray($config);


// custom_definitionが定義されていれば、カスタムタグを追加
if ($definitionConfig = $this->config->get('purifier.settings.custom_definition')) {
   $this->addCustomDefinition($definitionConfig, $configObject);
}


// custom_elementsが定義されていれば、カスタム要素を追加
if ($elements = $this->config->get('purifier.settings.custom_elements')) {
   if ($def = $configObject->maybeGetRawHTMLDefinition()) {
       $this->addCustomElements($elements, $def);
   }
}


// custom_attributesが定義されていれば、カスタム属性を追加
if ($attributes = $this->config->get('purifier.settings.custom_attributes')) {
   if ($def = $configObject->maybeGetRawHTMLDefinition()) {
       $this->addCustomAttributes($attributes, $def);
   }
}


return $configObject;

つまりこのことからわかることは、変に指定しない限りpurifier.settings.defaultに定義されたものが使われるということ。
なので、初期から設定されているpurifier.settings.testやpurifier.settings.youtubeは「Purifier::clean(“洗浄したいHTML”,”test”)」のように指定しない限り何も関与しない。

また、custom_definition、custom_elements、custom_attributesという特別なconfigがあることもわかった。

custom_definition、custom_elements、custom_attributesとは何か

前提として、HTMLPurifierは事前に定義されていないタグ名or属性名のものがあった場合、エラーを吐くようになっている

そして、HTMLPurifierはXHTML1.1想定してタグ名or属性名をデフォルトで定義している。

逆を言えば、HTML5.0で登場したタグoEmbedなどのオリジナルタグはHTMLPurifierに定義されていない

その為、定義されていないタグや属性を使いたい場合、この「custom_definition、custom_elements、custom_attributes」のconfigに自分で定義しなきゃいけないってこと。

さっきのconfigのcustom_difinitionをちょこっと見てみると

['section', 'Block', 'Flow', 'Common'],
['nav',     'Block', 'Flow', 'Common'],
['article', 'Block', 'Flow', 'Common'],
['aside',   'Block', 'Flow', 'Common'],
['header',  'Block', 'Flow', 'Common'],
['footer',  'Block', 'Flow', 'Common'],

のように既になんか定義されてるよね。

これはfor Laravelを作った作者さんの優しさで頻繁に使いそうなHTML5.0のタグを事前定義してくれたってこと。

だからXHTML1.1にないarticleタグとかをHTML.Allowedに入れても未定義エラーにならないんだね。

custom_definition、custom_elements、custom_attributesの定義方法

これはHTMLPurifierの公式ドキュメントにそれに近いものの記載があるのでそれを見るのが良いと思う。

一応自分の理解の為にも以下に記す。

また、理解のしやすさのためにcustom_difinitionを最後にする。

custom_attributes

これはカスタム属性を追加するconfig。

初期では以下のようになっている

'custom_attributes' => [
   ['a', 'target', 'Enum#_blank,_self,_target,_top'],
],

これは左から

  • 許可する属性の対象である要素名
  • 許可する属性名
  • 許可する値

を入力する。

上の例だとEnumで指定しているが他にもいろいろ指定方法はある。
詳しくは公式ドキュメントを。

custom_elements

これはカスタム要素を追加するconfig。
初期では以下のようになっている

'custom_elements' => [
   ['u', 'Inline', 'Inline', 'Common'],
],

これは左から

  • 許可する要素名
  • インライン要素かブロック要素か(Inline-Blockはできないみたい)
    • Inline:インライン要素
    • Block:ブロック要素
    • false:上記に適合しない型。(liやtrなど)
  • 許可する子要素
    • Empty:何も許可しない。(brやhrなど)
    • Inline:任意の数のInlineとテキスト。(spanなど)
    • Flow:任意の数のBlock,Inline,テキスト。(divなど)
  • 一般的な属性を持つか
    • I18N:lang属性を持つ
    • Core:style, class, id, titleの属性を持つ
    • Common:上2つどちらも

こんな感じ。許可する子要素は説明したもの以外にも細かく指定できる。
詳しくは公式ドキュメントを。

また、上の例には無いが5つ目の引数で属性を指定できる。
以下の例がわかりやすい。

['video', 'Block', 'Optional: (source, Flow) | (Flow, source) | Flow', 'Common', [
              'src' => 'URI',
'type' => 'Text',
'width' => 'Length',
'height' => 'Length',
'poster' => 'URI',
'preload' => 'Enum#auto,metadata,none',
'controls' => 'Bool',
          ]],

2次配列で指定できるんだね。

custom_definition

こいつがメインの定義config。

主に以下の5つの定義をする

  1. id
    公式ドキュメントのこの部分
    configの「’id’  => ‘html5-definitions’,」の部分。

    つまり、cacheに変更したことを知らせるためのIDってこと?
  2. rev
    公式ドキュメントのこの部分
    configの「’rev’ => 1,」の部分。

    IDは時系列を持たないけど、バージョンで指定すれば時系列がわかるよ! ってこと?
  3. debug
    configの「’debug’ => false,」の部分。

    for Laravel特有のものだけど、やってることはCache.DefinitionImplをnullにしてキャッシュを残さないようにしているだけ。
    つまり、設定を変えたら毎回読み込みなおしてすぐ反映されるようにしている。
  4. elemtns
    「custom_elements」で定義できたものをここでもできるようにしている。
  5. attributes
    「custom_attributes」で定義できたものをここでもできるようにしている。

わざわざcustom_definitionで要素と属性を定義する意味なんだけど、コード見た感じIDとVerの指定ができるのと、Debugが指定できるのがいいねって感じだった。

for Laravelを作った人的にcustom_definitionを使ってくれ感がすごいので、私はcustom_definitionで定義していこうと思う。

HTMLPurifierでURIのスキームを指定

安全なウェブサイトの作り方のXSSにもある通り、URLを出力させるときはスキームを「http」や「https」のみ許可したほうが良い。

HTMLPurifierでは属性の持てる値でURIというのを指定すると、「javascript:」などのURLを入れても洗浄してくれるようになる。

実際にjavascript:から始まるURLを入れてみると

しっかりURLが無くなっているのがわかる。

じゃあURIを指定するとデフォルトで何が許可されているのかってところなんだけど、公式ドキュメントによると以下の7つが許可されているみたい。

array (
 'http' => true,
 'https' => true,
 'mailto' => true,
 'ftp' => true,
 'nntp' => true,
 'news' => true,
 'tel' => true,
)

httpとhttpsはわかるんだけど、それ以外はそもそも知らないし必要ないと思うので許可したくない。

そんな場合はdefaultとかに
‘URI.AllowedSchemes’ => ‘http, https’,
を指定してあげればおk。

こうすれば「mailto:example@example.com?subject=件名」みたいなURLが入っていても洗浄してくれる。

TailwindCSSで記事専用CSSを適用する

なんか前にViteを使ったCSSとかJSの適用に苦戦していたけど、今回は上手く行ったのでそれを残す。
また、CSSファイルでTailwindCSSを使う方法も残す。

ViteでCSSとJSを適用する

前回惜しいとこまで行ったんだけど、npm run buildをするとなぜか適用されなくて諦めちゃった。
今回は上手く行ったのでその方法を。

簡潔に言えば、3ステップ

  1. 「resources/」にCSSやJSファイルを作る
  2. 読み込みたいviewで
    @vite([“resources/css/article/show.css”, “resources/js/article/show.js”])
    のように@viteでそれを使う宣言をする
  3. 「vite.config.js」のinputで読み込む。

マジでこれだけだった。

前回上手く行かなかったのは、なぜかvite.config.jsじゃなくresources/js/app.jsで読み込もうとしちゃったから。
なんかどっかに「app.jsでも読み込めるよ!」みたいな記述があった気がしたんだけど、日本語ドキュメント見たらなかったので見間違いなのかなぁ。

npm run dev状態ならvite.config.jsのinputで読み込まなくてもViteの開発サーバーからjsやらcssやらを読み込んでくれるんだけど、vite.config.jsのinputに記述しておかないとnpm run buildするときにビルドしてくれないという感じ。

CSSファイル内でTailwindCSSを使う

なぜTailwindCSSを使っているのにCSSファイルを使うのかというと、ユーザーが作った記事などの動的に生成されるタグに対して一括でCSSを適用したいから。

恐らく、PHPやJSでDOM解析して付けたりすることも可能なんだけど、CSSファイルを読み込んじゃうのが一番簡単だよねという感じ。

その上で、私は生CSSを学校の授業とかでしか書いたことがないので、出来ればCSSファイルを弄るときもTailwindCSSを使いたい。

そんな時に使えるのが@applyである。

以下のように書くことでTailwindCSSのユーティリティクラスをCSSに展開してくれる。

h1 {
   @apply text-3xl font-extrabold my-4
}

めっちゃ便利!

しかし、「TailwindCSSのapplyは何が悪いのか」のように利用に警鐘を鳴らしている人もいるので、今回のような特異なケースでのみ利用していきたい。

文字がはみ出る問題

毎回CSSに苦戦している気がする。

記事閲覧のCSSを調整していたら、はみ出る問題に遭遇しまくったので躓きそうなところをまとめる。


具体的には、

こんな感じ。

CSSでの解決法は以下の3通り

  1. line-break
    中国語、日本語、韓国語に対する改行規則
  2. word-break
    改行しないとボックスから文字があふれるときどうするか。
  3. overflow-wrap
    改行しないとボックスから文字があふれるときどうするか。

結論から言えばoverflow-wrapのanywhereが良さげ

line-breakの違いはわかりやすいんだけど、他2つの違いが私には本当にわからない。
恐らく計算方法や歴史の違いなんだけど、今回の本質ではないのでとりあえずoverflow-wrapのanywhereが良いよということを。

どう良いかは以下を見ていただけると。
左が「overflow-wrap: anywhere;」で右が「word-break: break-all;」

左のoverflow-wrapの方はURLを見やすく改行してくれていたり、記号の羅列も改行できている。
それに代わって右のword-breakはURLの途中で改行されており、記号はなんなら改行できていない。

overflow-wrapはanywhere以外にもbreak-wordというのもあるんだけど、私には違いを説明できないので、Qiitaでも採用されているanywhereをとりあえず使う。

久しぶりにやるとブログとIsuueに助けられる

ここ最近他が忙しくて1週間弱の間Web制作を離れていたんだけど、マジでなにやってたのか思い出せない。
ちょっとやらないだけでかなり判断力とか考え方が抜け落ちている感じがする。

でも、こうやって記事にしていると過去の私の意図とか思考がある程度思い出せるのでかなりありがたい。
あと、GitHubでIssue管理していると目的が明確になるので更にありがたい。

こうゆうのって工夫が大事だなぁと思った。

記事の編集画面と閲覧画面のCSS差について

ユーザーは記事の編集画面を見て記事を執筆し、閲覧画面で閲覧する。
つまり、編集画面の記事と閲覧画面の記事の見え方ができるだけ変わらないようにしたい。

しかもめっちゃ欲張るとレスポンシブデザインにもしたい。

私が考慮する必要があるのは

  • CKEditor特有のCSS
  • CKEditor編集時と閲覧時でそもそもタグが違う問題
  • 閲覧時、編集時でレイアウトを同じにしたい

いろいろ考えることはあるんだけど、CSSは後からでも変更可能なので出来るだけシンプルで見やすく簡単に考えたい。

また、理想ではあるんだけど文字の折り返し位置とかを同じにしたい。
他のサイトでは文字の折り返し処理はどうなっているんだろう時になったので、Qiita、Zenn、Noteをそれぞれ見てみる。

以下は全部1920*1080のフルスクリーン時

  • Qiita
    編集時と閲覧時で文字の折り返し位置は一致している。
    そもそも文字を記入するboxサイズと表示する横サイズが708pxで一緒。

    画面の比率を変えるとずれる。
  • Zenn
    編集時と閲覧時で文字の折り返し位置は不一致
    編集時723.600pxで閲覧時708.400px。

    なんならフォントも違う。
  • Note
    編集時と閲覧時で文字の折り返し位置は同じ
    どっちも横幅が620px。
    画面の比率を変えるとずれる。


仕様上、比率を変えても折り返し位置がずれないようにするには、折り返しまでの幅を固定化するしかないと思う。でもそうすると柔軟性に欠けるのでそこは妥協しているという感じ。

そんで、QiitaとNoteは編集時と閲覧時の横幅が一緒になるので文字の折り返し位置やCSSも同じように見えるという感じ。

これ、編集時と閲覧時で横幅が同じ大きさになる仕組みを知りたいよね。

Qiita閲覧画面の構成
私の画面構成はかなりQiitaを参考にしているので、この部分もQiitaのCSSを覗いてみる。

以下で記述しているのは全部1920*1080のフルスクリーン時の話。

Qiitaの閲覧画面は主に以下のように構成されている。

表示はこんな感じ

めっちゃ簡単に言うとclass=”style-qgd36e”が以下三つの子要素をflexで配置しているという感じ。

真ん中のメインは可変で右端は300pxで固定。左は固定されてないけど、最初から最小なのでこれ以上小さくなりようがないという感じ。

  • class=”style-yrmhnf”
    左側のいいねやストック部分。
    widthは40pxで、ハートのボタンが40pxなのでこれ以上小さくならない。
  • class=”p-items_options”
    右側の広告や目次の部分。
    widhtは300pxで固定。広告の大きさの兼ね合いだと思う。
  • class=”p-items_main”
    真ん中の記事やコメントの部分。
    widthは
    max-width: 820px;
    width: calc(100% – 40px – 300px – 112px)
    が指定されているので、ウィンドウ幅によって可変する。
    -40は左側で、-300は右側、-112は空白の分だと思われる。

この3要素を親要素「class=” style-qgd36e”」で管理していて、この親要素には
display: flex;
gap: 56px;
padding: 16px 24px 0px
がついている。

flexとgapでいい感じに3要素を配置しているという感じだと思う。

bootstrapにあるcontainerの意味と利点

Breezeを入れたから? なのかテンプレートにあるcontentの親要素に「<div class=’container’>」が適用されている。
CSSを考える上でこのcontainerがややこしくしているのでフォーカスを当ててみる。

公式ドキュメントによると、containerは以下のようにウィンドウの横幅に応じてcontainer要素の横幅を指定するものみたい。

Extra small<576pxSmall≥576pxMedium≥768pxLarge≥992pxExtra large≥1200px
.container100%540px720px960px1140px
.container-sm100%540px720px960px1140px
.container-md100%100%720px960px1140px
.container-lg100%100%100%960px1140px
.container-xl100%100%100%100%1140px
.container-fluid100%100%100%100%100%

私の場合通常のcontainerが付いているので、横幅は5段階で変化する。
わかりやすいDemoをBootStrap公式が置いてくれているので、それを見るのがおすすめ。

これの何が嬉しいのかなんだけど、主なメリットはBootStrapのグリッドシステムが利用できるようになることみたい。
BootStrap全くわからんのでグリッドシステムについてはスルー。

他に考えられるメリットだと、横幅をある程度固定することで予期せぬCSS崩れを回避できるとかかなぁと思った。

containerのデメリット

なんでこのcontainerを話題に挙げているのかというと、デメリットがあるから。

このcontainer、幅を固定するという仕様上、どうしても幅を調整する為の空白ができてしまう。

例えば、containerで一番大きい横幅であるExtra largeは1200px以上で適用されるが、逆に言えば1199pxまでその一個下のlargeの横幅で固定されてしまう。
つまり、1199pxの時、containerの横幅は960pxで固定され余分な239pxの空白を要する。

その為、以下のように横に大きい空白ができてとても本文が見にくい感じになってしまう。

できれば「左側のいいね」と「右側の広告/目次」の横幅は固定で、本文は横幅に応じてパンパンに表示してほしい。

その為、私はcontainerを削除して「<div class=’w-full px-3 lg:px-8′>」にした。

最終的にいろいろ調整していい感じになった。

ナビバーがまだ非レスポンシブなので後でそこも調整したい。

閲覧と編集ページで記事の表示幅も同じなので、改行される位置とかCSSの見え方とかも編集時とだいたい同じになるはず。

サニタイズのテスト

今、私はCKEditorのフロントエンドによるエスケープとHTMLPuriferのバックエンドによるエスケープ/サニタイズで記事を洗浄している。

フロントの洗浄は直接リクエスト送ればスルーできるので実質HTMLPurifierのみなのだけど、意図しないサニタイズやサニタイズ漏れがあると怖いのでテストを作りたい。

つまり、サニタイズ/エスケープのテストをしたい。

……

テスト作成完了。特に書くこともないので、何も記録してない。

意味あんのかなぁと思ってたHTMLPurifierのテストを作ることで、HTMLPurifierで見逃していたタグを発見。マジでテスト大事。

レート制限

レート制限のカウント方法

今までなんとなくでレート制限をかけていたんだけど、このレート制限ってリクエストの種類毎のカウントなのか、ミドルウェアの種類毎のカウントなのか、IP・ID毎のカウントなのかが気になる。

リクエストやミドルウェア毎ならいいんだけど、IP・ID毎にカウントするならレート制限は緩めじゃないと予想よりも早く制限にひっかかりそう。

ちょっと実験してみた感じはミドルウェア毎の制限っぽかった。
理由は、同じ制限ミドルウェアを複数のリクエストに付けたら、その制限は共有された。しかし、異なる同条件の制限ミドルウェアを複数のリクエストに付けたら、その制限は共有されなかったので。

しかし、同じミドルウェアを付けているとカウントや制限が共有されてしまうのでそれを考慮した値にしなきゃいけなさそう。
というか、基本緩めでいいのかな。なんかあったら強くする方針で。

全てのリクエストに制限をかける

どうしても不安だったので、webへのリクエスト全体に3分間に300回のレートリミットをかけてみた。

perMinutesというメソッドがあるのでこれを利用し制限を作成し、webのミドルウェアグループに入れた感じ。

3分間に300回の理由は、1分間に100回ならぎり本来の用途でもありそうだけど、3分の間1分平均100回は本来の利用用途を逸脱してそうと思った為。

CKEditorの埋め込みembedについて

ちょっと疎かなので見直す。

仕組み

対応メディア

そもそもCKEditorのembedはここにあるメディアしかデフォルトで対応してない。

この中でもプレビューできるメディアと出来ないメディアがある。

  • プレビュー可能
    • ‘dailymotion’
    • ‘spotify’
    • ‘youtube’
    • ‘vimeo’
  • プレビュー不可
    • ‘instagram’
    • ‘twitter’
    • ‘googleMaps’
    • ‘flickr’
    • ‘facebook’

プレビュー可能と不可の違いは、oEmbed APIの利用に制限があるか否かだと思われる。
youtubeやspotifyは誰でもoEmbedのAPIを叩けるが、instagramなどは開発者登録しないとそもそも叩けない。そんな違いがあるからかな。

データの出力形式

CKEditorが対応しているといっても、「編集画面でのプレビュー」に対応しているだけであり、出力されるコードは以下のようになっている。

<figure class="media">
    <oembed url="https://media-url"></oembed>
</figure>

そのため、view画面ではこのコードを使い自分でoEmbedのAPIを叩くコードを書く必要がある

一応、
mediaEmbed.previewsInDataをtrueにすればプレビューできるメディアは以下のようにプレビューされたHTMLで保存されるらしい。

<figure class="media">
    <div data-oembed-url="https://media-url">
        <iframe src="https://media-preview-url"></iframe>
    </div>
</figure>

oEmbedのAPIを叩いてくれるサービス

一応、自分でJSを書かなくてもあらゆるoEmbedに対応してくれるサービスがある。
CKEditorではiframelyembedlyが紹介されている。

無数のoEmbedに対応するなら便利だと思うが、値段はかなり張る。

これってつまり記事にoEmbedが1個入ってたら1回閲覧される毎に1回処理されるから1hitだよね? 私のブログでさえ月に数千PVあるので、記事共有サービスだと1000hitsはすぐ超えそう。
月50$でさえ厳しいのと、とりあえず数種類のoEmbedに対応するのみの予定なので私はスルー。

oEmbed対応メディアを拡張する

extraProvidersというプロバイダーを使い、自分で好きなメディアを追加できる。

ClassicEditor
   .create( editorElement, {
       plugins: [ MediaEmbed, ... ],
       mediaEmbed: {
           extraProviders: [
               {
                   name: 'extraProvider',
                   url: /^example\.com/media/(\w+)/,
                   html: match => '...'
               },
               ...
               ]
       }
} )
.then( ... )
.catch( ... );

といっても、自分でそのメディアAPIの仕組み、利用規約を理解する必要があるのでかなり大変。

デフォでoEmbed対応のメディアを削除する

こちらはremoveProviderでできる。

ClassicEditor
    .create( document.querySelector( '#editor' ), {
        plugins: [ MediaEmbed, /* ... */ ],
        toolbar: [ 'mediaEmbed', /* ... */ ]
        mediaEmbed: {
            removeProviders: [ 'instagram', 'twitter', 'googleMaps', 'flickr', 'facebook' ]
        }
    } )
    .then( /* ... */ )
    .catch( /* ... */ );

私の場合、とりあえずyoutubeとspotifyだけ残そうかなと思う。

日本語特有のCKEditorのSpotify問題

Spotify、なぜか「https://open.spotify.com/intl-ja/track/7pvTLJOjJPbS97gnTfH6uH」のようにSpotifyが日本語設定の場合「intl-ja」といったように謎のパスが挟まる。

このせいで、日本語設定で取得したURLでは以下のgifのようにCKEditorがSpotifyと判断してくれない。

「intl-ja」を消せば普通に反応する。マジでどうしたもんかこれ。

ソースコードを弄ってintl-ja問題を解決する

media-embedのソースコードを眺めていたらSpotifyの正規表現らしきものを発見。

name: 'spotify',
url: [
    /^open\.spotify\.com\/(artist\/\w+)/,
    /^open\.spotify\.com\/(album\/\w+)/,
    /^open\.spotify\.com\/(track\/\w+)/
],

ここを弄ってintl-jaも許可するようにすれば良さそう。

つまり、

                    name: 'spotify',
                    url: [
   /^open\.spotify\.com\/(?:intl-[a-z]{2}\/)?(artist\/\w+)/,
    /^open\.spotify\.com\/(?:intl-[a-z]{2}\/)?(album\/\w+)/,
    /^open\.spotify\.com\/(?:intl-[a-z]{2}\/)?(track\/\w+)/
                    ],

こうしてみた。ja以外にも何ヵ国か該当する国があったので、汎用的になるよう工夫。

でも、これnode_modulesを直で弄らない方がいいよね。

一回node_modulesを直で弄って思った通りになるかどうかを見てみる。

おk。

直貼りでも反応するのでこれで問題なさそう。

patch-packageを使い、node_modulesを修正する

node_modulesを直接弄るとアップデートしたときに上書きされちゃったり、変更履歴が追えなかったりするので、パッチ(変更点)を別で管理する方式をとる。

その為にpatch-packageというツールを使う。

インストールできていないので
npm install patch-package postinstall-postinstall –save-dev
でインストール

インストールしたら、修正を加えたいnode_modulesを直接修正。

修正したら今回はmedia-embedを修正するので
npx patch-package @ckeditor/ckeditor5-media-embed
を実行。

するとpatchesディレクトリができる。

最後にpackage.jsonに以下のようなコードを追加し、インストールのたびにパッチを当てるようにすれば完成。

"scripts": {
    "postinstall": "patch-package"
  }

Youtubeの埋め込みに対応する例

CKEditorで対応しているのはURLの認識とプレビューまでで、閲覧画面でYoutubeを埋め込むには自分で

<figure class="media">
    <oembed url="https://media-url"></oembed>
</figure>

のようなコードをなにかで変換してあげる必要がある。

これ、結構私にとってハードルが高かったのでこの方法で正解かはわからないけど、Youtubeの埋め込みに対応する方法だけここに残しておく。

CKEditor側の処理を理解する

そもそもCKEditorでどう処理されているかを理解しないと、こっちでどう処理するかがわかんないよね。

以下はMediaEmbedEditingのyoutubeの部分。

                {
                    name: 'youtube',
                    url: [
                        /^(?:m\.)?youtube\.com\/watch\?v=([\w-]+)(?:&t=(\d+))?/,
                        /^(?:m\.)?youtube\.com\/v\/([\w-]+)(?:\?t=(\d+))?/,
                        /^youtube\.com\/embed\/([\w-]+)(?:\?start=(\d+))?/,
                        /^youtu\.be\/([\w-]+)(?:\?t=(\d+))?/
                    ],
                    html: match => {
                        const id = match[1];
                        const time = match[2];
                        return ('<div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 56.2493%;">' +
                            `<iframe src="https://www.youtube.com/embed/${id}${time ? `?start=${time}` : ''}" ` +
                            'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' +
                            'frameborder="0" allow="autoplay; encrypted-media" allowfullscreen>' +
                            '</iframe>' +
                            '</div>');
                    }
                },

これは、CKEditorに張り付けられたURLが

/^(?:m\.)?youtube\.com\/watch\?v=([\w-]+)(?:&t=(\d+))?/,
/^(?:m\.)?youtube\.com\/v\/([\w-]+)(?:\?t=(\d+))?/,
/^youtube\.com\/embed\/([\w-]+)(?:\?start=(\d+))?/,
/^youtu\.be\/([\w-]+)(?:\?t=(\d+))?/

の正規表現に当てはまれば

'<div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 56.2493%;">' +
`<iframe src="https://www.youtube.com/embed/${id}${time ? `?start=${time}` : ''}" ` +
'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' +
'frameborder="0" allow="autoplay; encrypted-media" allowfullscreen>' +
'</iframe>' +
'</div>'

のようなHTMLを生成するというもの。

HTMLを生成して表示……? oEmbedみたいにAPI通信しなくていいの?

そう、これはoEmbedではない。

そもそもoEmbedとは何だったのか

公式ドキュメント

oEmbedは外部サービスを埋め込む為のフォーマット。

公式の例を引用すると、ブログなどで
「http://www.flickr.com/services/oembed/?format=json&url=http%3A//www.flickr.com/photos/bees/2341623661/」のようなoEmbed規格のHTTPリクエストを送る。

するとoEmbedに対応しているサイトなら

{
	"version": "1.0",
	"type": "photo",
	"width": 240,
	"height": 160,
	"title": "ZB8T0193",
	"url": "http://farm4.static.flickr.com/3123/2341623661_7c99f48bbf_m.jpg",
	"author_name": "Bees",
	"author_url": "http://www.flickr.com/photos/bees/",
	"provider_name": "Flickr",
	"provider_url": "http://www.flickr.com/"
}

のようにJSONまたはXMLでHTTPレスポンスを返してくれる。

このレスポンスから埋め込みをJSかなんかで実装するという感じ。

oEmbedはこのやり取りのルール、つまりフォーマットを決めているということ。

これ何が嬉しいのかなんだけど、oEmbedはレスポンスのフォーマットが決まっているので、埋め込みたいサービスがoEmbedに対応していて、そのリクエスト先さえわかれば後は簡単に埋め込める。
つまり、簡単に埋め込み対応ができる。それが嬉しいポイント。

SoundCloudで実際にoEmbedの処理をみてみる。

SoundCloudにoEmbedの要求をしてみる

SoundCloudはoEmbedに対応しており、リクエスト先は「https://soundcloud.com/oembed」になる。

SoundCloudのoEmbedは以下のようになっているみたい。

oEmbedの規格的に、urlさえ入れればレスポンスは返ってくるはずなので、私のアカウントのURLを入れてoEmbedの要求をしてみる。

curl “https://soundcloud.com/oembed?url=https://soundcloud.com/tamakoma” | jq .
jqというのはレスポンスのJSONを見やすくするためのもの。

{
    "version": 1.0,
    "type": "rich",
    "provider_name": "SoundCloud",
    "provider_url": "https://soundcloud.com",
    "height": 450,
    "width": "100%",
    "title": "tamakoma",
    "description": "こんばんは",
    "thumbnail_url": "https://i1.sndcdn.com/avatars-zR4dRiBGzGyk24Nm-edIQyg-t500x500.jpg",
    "html": "<iframe width=\"100%\" height=\"450\" scrolling=\"no\" frameborder=\"no\" src=\"https://w.soundcloud.com/player/?visual=true&url=https%3A%2F%2Fapi.soundcloud.com%2Fusers%2F274756531&show_artwork=true\"></iframe>",
    "author_name": "tamakoma",
    "author_url": "https://soundcloud.com/tamakoma"
}

こんな感じで返ってきた。

rich形式で帰ってきたので、htmlが必ずついてくる。
このHTMLをGoogleChromeで閲覧してみると

しっかりこんな感じで表示される。簡単だね。

でもこれ、表示している技術はoEmbedではない。
oEmbedは埋め込む為のやり取りを規格化したものであり、埋め込む技術ではないということ。

さっきのHTMLを見てみると…….

<iframe width=\"100%\" height=\"450\" scrolling=\"no\" frameborder=\"no\" src=https://w.soundcloud.com/player/?visual=true&url=https%3A%2F%2Fapi.soundcloud.com%2Fusers%2F274756531&show_artwork=true></iframe>

どうやらiframeというタグが埋め込みを実現させているみたい。

ここを詳しく見ていきたい

iframeとは何なのか

Webページの中に別のWebページを埋め込む技術はiframeというタグで実現されている。

mdnのiframeの説明をそのまま引用すると

<iframe> は HTML の要素で、入れ子になった閲覧コンテキストを表現し、現在の HTML ページに他のページを埋め込むことができます。

https://developer.mozilla.org/ja/docs/Web/HTML/Element/iframe

つまり、閲覧ページの入れ子ができるということ。

これ、iframeを使う度に新しいページを開いているのと同義ではあるので、リソースに注意しなきゃいけない。

試しに、iframeを使いサイトの中でこのブログを表示してみる。

うん、サイトはそのままに私のブログを閲覧できている。

勿論、iframe提供者側はhtaccessで拒否したり、制限を付けたりもできるみたいだし、利用者側としては怪しいサイトをiframeで開け無いようにしないといけない。

つまり、何が言いたいかというと、YoutubeやSoundCloudはこのiframeで綺麗に埋め込む用のページを用意していて、埋め込むときはiframeでそのページを表示しているということ。

実際にiframe用のURLにブラウザで普通にアクセスしてみると

こんな感じで音楽のウィジェット以外は何も表示されない画面が出てくる。

したがって、iframe用のURLがわかればoEmbedを使わなくても問題ない場合がある(URL以外にもセキュリティや規約を満たす必要がある)。

だから、CKEditorではoEmbedのAPI通信をせずにサイトを表示できていたんだね。

JavaScriptでoEmbedタグの処理を行う危険性

iframeで表示しているので、oEmbedのAPI通信を必ずしもする必要はない事が分かったところで、メインの処理部分を書いていきたい。

通常、埋め込み可能なYoutubeのURLをCKEditorが検知すると

<figure class="media"><oembed url="https://m.youtube.com/watch?v=MyV8DhouDXY&t=58s&ab_channel=%E5%85%AB%E7%8E%8B%E5%AD%90P"></oembed></figure>

のようなHTMLに変換して保存してくれる。

oembedというタグはCKEditorオリジナルのものであり、特に何か処理をしてくれるわけではない。

その為、どこかのタイミングでこのoembedタグどうにかして埋め込み対応のHTMLに変換してあげる必要がある。

これ、最初私は閲覧ページでJavaScript書いてoembedタグをiframeに変換するように書いてたんだけど、この処理は結構危ない事に気づいた

ユーザーが投稿した情報から動的に新しいHTMLをHTMLPurifierを通さずに生むことになるので、予期せぬ脆弱性がおこる可能性がある。

例えば、

<iframe src="https://www.youtube.com/embed/${videoID}~

のような感じでiframeを生成しようとすると、$videoIDの値が”><script></script><iframe src=”みたいな感じだとscriptタグが成立してしまう……よね?

とにかく、JavaScriptで外部変数を使って動的にHTMLを生成するのはめっちゃ危ない

つまり、外部変数を使って動的に生成するHTMLは絶対にHTMLPurifierを通したいので、CKEditorの設定を変えてiframeをそのまま保存するようにする or iframeをバックエンドで生成し、HTMLPurifierで特定のiframeを許可するようにしたい。

CKEditorではmediaEmbed.previewsInDataをTrueにすることで、プレビューされたHTMLをそのまま保存することができる。

  • メリット
    • 実装が楽
    • CKEditorの編集画面と同じ表示になる
  • デメリット
    • iframeを固定で保存してしまうので、後からの表示の変更ができない
    • つまり、汎用性が低い

確かに後から表示方法の変更はできないけど、ユーザーが今見ているビジュアルと同じものになるという点ではかなり優れている。

一回、mediaEmbed.previewsInDataをTrueにした状態でHTMLPurifierの設定を見てみる。

HTMLPurifierでiframeを許可する

XHML1.1の頃にはiframeはあったようで、HTMLPurifierは幸いにもiframeに対応している。

対応の仕方は、HTML.SafeIframeをtrueにし、URI.SafeIframeRegexpで許可するURIをPCRE正規表現で設定し、HTML.Allowedを設定すれば完了!

試しに表示してみた

なるほど、div要素とかそのCSSを許可してないからフルで表示されないのね。

うーん。わかった。

  1. CKEDitorからサーバー時にはoembedタグで保存
  2. サーバーから取り出すときにoembedをiframeに変換
  3. HTMLPurifierで洗浄
  4. 表示

という感じにする。

HTML.SafeIframe=trueを設定しなければoembedタグで勝手に保存されるので、設定を解除。

取り出すときにoembedをiframeに変換するところなんだけど、Articleのモデルにメソッドを書いて取り出す形に。

public function oembedToIframe($html)
{
   $dom = new \DOMDocument();

   $dom->loadHTML(
       '<?xml encoding="UTF-8"><article>' . $html . '</article>',
       LIBXML_NOERROR | LIBXML_HTML_NOIMPLIED
   );
   // 配列に入れて操作しないと、DOMの追加や削除時にズレる
   $oembeds = iterator_to_array($dom->getElementsByTagName('oembed'));

   //oembedタグの取得と処理
   foreach ($oembeds as $element) {
       $rawUrl = $element->getAttribute('url');
       $hostname = parse_url($rawUrl, PHP_URL_HOST);

       // youtube.com、www.youtube.com、m.youtube.com、youtu.beのいずれだったら
       if (preg_match('/^(www\.|m\.)?youtube\.com$|^youtu\.be$/', $hostname)) {
           $videoID = null;
           $startTime = null;
           $youtubePatterns = [
               '/^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?v=([\w-]+)(?:.*?(?:&|amp;)t=(\d+))?/',
               '/^https?:\/\/(?:www\.)?youtube\.com\/embed\/([\w-]+)(?:\?.*?(?:&|amp;)start=(\d+))?/',
               '/^https?:\/\/(?:www\.|m\.)?youtube\.com\/v\/([\w-]+)(?:\?t=(\d+))?/',
               '/^https?:\/\/youtu\.be\/([\w-]+)(?:\?.*?(?:&|amp;)t=(\d+))?/'
           ];

           foreach ($youtubePatterns as $pattern) {
               if (preg_match($pattern, $rawUrl, $matches)) {
                   $videoID = $matches[1];
                   $startTime = $matches[2] ?? null;
                   break;
               }
           }
           if ($videoID) {
               $iframeHTML = '<div class="iframe-youtube">' .
                   "<iframe src=\"https://www.youtube.com/embed/{$videoID}" . ($startTime ? "?start=${startTime}" : '') . "\" " .
                   "title=\"YouTubeの埋め込み\" " .
                   'allow="autoplay; encrypted-media" allowfullscreen>' .
                   '</iframe></div>';

               // iframeHTMLをDOMオブジェクトにし、置換する
               $newDocument = new \DOMDocument();
               $newDocument->loadHTML(
                   '<?xml encoding="UTF-8">' . $iframeHTML,
                   LIBXML_NOERROR | LIBXML_HTML_NOIMPLIED
               );
               // deepをtrueにし、子要素も読み込む
               $newNode = $dom->importNode($newDocument->documentElement, true);

               $element->parentNode->replaceChild($newNode, $element);
           }
       }
   }

DOMの操作はこちらにまとめている。

このコードの難しそうというか、難しいのが正規表現の部分。

例として「’/^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?v=([\w-]+)(?:.*?(?:&|amp;)t=(\d+))?/’」を解説する。

  • /^https?:\/\
    • 「/」が正規表現の始まりを意味する。文字列なら「”」で囲むのと同じ感じ。
    • 「^」がそれ以降の文字で始まるという意味
    • 「?」は0文字または1文字という意味。つまりsがあってもなくてもいい
    • 「\/\/」は単純に「/」をエスケープ処理して「\/」になってるだけ
  • (?:www\.|m\.)?
    • 丸括弧はグループ化。グループ毎にマッチした文字列を参照できる
    • ?:はグループを参照しませんという意味
    • www\.|m\.の|は「または/or」
      • wwwから始まる場合もあるし、m(モバイル版)から始まる場合もある
    • 最後の?は同じく0文字または1文字
  • youtube\.com\/watch\?v=
    • \?は「?」をエスケープ
  • ([\w-]+)
    • 丸括弧でグループ化
    • []は中に括弧内のもじのどれか
    • \wは「_0-9a-zA-Z」どれか
    • +は1文字以上
    • つまり、「_0-9a-zA-Z-」のいずれかの文字が1文字以上続く
    • グループ化してるので、後で参照できる
  • (?:.*?(?:&|amp;)t=(\d+))?
    • 最後に?があるので、あってもなくてもよい
    • .*は任意の文字が何文字でも
    • .*?の「?」は最左最短マッチつまり、残りの「(?:&|amp;)t=(\d+)」に当てはまった時点で探索を終える
    • (?:&|amp;)は「&」または「amp;」
    • (\d+)の「\d」は半角数字いずれか「+」で1個以上を丸括弧で参照できるようにしている

大体こんな感じでできている。

また、

$videoID = $matches[1];
$startTime = $matches[2] ?? null;

で正規表現で当てはまったところを参照している。[1]がグループ1個目みたいな感じ。

生成するHTMLは

$iframeHTML = '<div class="iframe-youtube">' .
   "<iframe src=\"https://www.youtube.com/embed/{$videoID}" . ($startTime ? "?start=${startTime}" : '') . "\" " .
   "title=\"YouTubeの埋め込み\" " .
   'allow="autoplay; encrypted-media" allowfullscreen>' .
   '</iframe></div>';

こんな感じ。

divにclass=””をつけてあげることで後からCSSを適用できるように。
iframeのtitleは音声読み上げ時に読み上げられるので付けている。
allowのencrypted-mediaは認証が必要な動画の場合、認証処理ができるようになるようにするためのものらしいが、正直よくわかってない。

そんで最後に置換して処理完了。

CSSは以下のように

div.iframe-youtube {
   position: relative;
   height: 0;
   padding-bottom: 56.25%; /* 16:9 Aspect Ratio (9/16 = 0.5625) */
}
div.iframe-youtube iframe {
   position: absolute;
   top: 0;
   left: 0;
   width: 100%;
   height: 100%;
   border: none; /* frameborder="no"の代替 */
}

CSSはCKEditorのをほぼ拝借したんだけど、仕組みとしては

paddingの%指定の裏技を使っている。

  • heightを0にして、paddingでiframeの範囲を指定
  • paddingのbottomは横幅の比率、つまり上の処理だと横幅×56.25%になるので比率を維持できる
  • postion: absoluteで位置の指定。位置はデフォでは親要素の左上からの相対的な位置なので、top0、left0にして、width、heightを100%にすることで親要素パンパン表示に。
  • border: noneはiframeの廃止された属性frameborder=”no”の代替。

いいね。

他にも同じようにspotifyとsoundcloudの処理も書いてみた。

レスポンシブだし、結構いい感じ。

懸念点としては、正規表現に漏れがあるかもとかかなぁ。

Bootstrapをやめる

今まで、デフォで一部のviewにBootstrapが使われていたのでそのまま使ってたんだけど、Bootstrapをやめようと思う。

理由は、

  • CSSを弄ろうとしたとき意図せずBootstrapでCSSを上書きされる
  • それに気づけずCSSに苦戦する
  • Bootstrapを利用している箇所としてないところでCSSに差ができる
  • コンポーネント使うとCSS学べない

が理由。

Bootstrap学べば解決しそうではあるんだけど、より生に近いユーティリティクラスのTailwindCSSでCSS自体を学びながら進めたいのが1番の大きな理由。

開発効率を考えるとBootstrapは素晴らしいこと、Tailwindは後の負債になる可能性が高いことは知ってるんだけど、学ぶことも考えて一旦Tailwind一本にしたいと思う。

私の場合、Bootstrapはlayoutで
<link rel=’stylesheet’ href=’https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css’>
のようにmdnで読み込まれているので、これを外せばBootstrapのCSSは全部外れるはず。

外すと

見事にCSSが崩れた。

Laravelにはコンポーネント機能があるので、エラーメッセージやボタンはコンポーネントで作成し、h1とかは個別にtailwindで設定していくのがいいかなぁ。

Bootstrapをやめた後のCSS

Bootstrapをやめ、CSSを書き直すに当たって苦戦したところをメモ。

z-index

HTMLのレイヤーの順序が決まるには、2つの要素が関わってくる。

1つがコードの順序。HTMLのコードで後(下)に書いてある要素の方が前に表示される。
2つ目がz-index。このz-indexはグローバルの話ではなく同じ親を持つ子どうし、つまり兄弟間での比較になる。
ちなみに、z-indexはstatic以外のpotisionを指定してあげなきゃいけないので注意。

ここで問題になってきたのが以下のような場合。

この画面は記事を管理するための画面。
「…」を押すことでドロップダウンリスト表示されるんだけど、このドロップダウンリストが下の記事の要素にレイヤー順序で負けちゃってる。

これは、ドロップダウンリストは次の記事の要素よりHTML上で前(上)に書かれているから。

じゃあ、index-zでドロップダウンリストのレイヤー順序を定めてあげればいいんじゃない?って思うんだけど、コードを見てほしい。

簡潔にすると記事1つあたりのコードは以下のようになっている。

<li>
   <div>
       <div>
           <!--編集ボタン-->
           <a href="">
               編集
           </a>
           <!--ドロップダウン-->
           <x-dropdown>
               <x-slot name="trigger">
                   <button>...</button>
               </x-slot>
               <x-slot name="content">
                   リスト内要素
               </x-slot>
           </x-dropdown>
       </div>
   </div>
</li>

このコードのかたまりがループして記事のリストになってるイメージ。
つまり、このかたまりのルートタグであるliの親はul。

このようなコードの時、

<x-slot name="content" class="z-50 relative">

と指定してもドロップダウン内のコンテンツは下の段の記事より前面に来ない。

これは前述した通り、z-indexは兄弟同士でしか比較しない。なので、x-dropdownを親に持つ子タグどうしでしか比較できず、前面に出てこないということ。

これを解決する策として、何個か残しておきたい。

  1. 被りたくない要素よりも前面になるところでドロップダウンのコードを宣言し、絶対位置で無理やり表示する
    正直その場しのぎ感があるので避けたい。
  2. 各記事のルートタグに対し、昇順に数字を降順でz-indexを割り振っていく
    こちらはバックエンドで処理できるのが魅力だが、動的にz-indexを変えるのがわからなかったのでパス。
  3. ドロップダウンリストは必ず下に出てくるのを利用し、下から順に並べるようにする
    確かに上にある要素の方がレイヤーで上にくるので解決はするが、ドロップダウンリストの仕様を変えると修正が必要になりそう。
  4. ドロップダウンボタンを押したとき、各記事のルートタグに対しz-indexを極端に高い数値で指定する
    これはJavaScriptによる処理になるのがJSがわからない私にとってマイナスポイントではあったけど、ドロップダウンを開くボタンは既に用意されていたのでこれに決定。

他にも策はありそうだけど、とりあえずそんな感じ。

具体的な仕組みは次を。

Alpine.jsによるz-indexの動的な変化

やりたいこととしては、ドロップダウンのボタンを押したとき、アクティブな要素と被りたくない要素の親を辿ったときの共通の親を持つ子の部分で、アクティブな要素の方のz-indexを一番上に持ってくるという作業。

上のプロセスは、ボタンを変えるだけで実装できる。

私が実装したのは以下の通り。

<li>
   <div>
       <div>
           <!--編集ボタン-->
           <a href="">
               編集
           </a>
           <!--ドロップダウン-->
           <x-dropdown>
               <x-slot name="trigger">
                   <button @click="$el.closest('li').style.zIndex = 9999"
                           @click.outside="$el.closest('li').style.zIndex = 0"
                           @close.stop="$el.closest('li').style.zIndex = 0" type="button">
                       ...
                   </button>
               </x-slot>
               <x-slot name="content" class="z-50 relative">
                   リスト内要素
               </x-slot>
           </x-dropdown>
       </div>
   </div>
</li>

liタグの親は表記していないがulタグで、上のコードのかたまりをループさせてリストを作ってるので、liタグのz-indexを動的に変更させてあげればいい。

ボタン部分をみていく。

<button @click="$el.closest('li').style.zIndex = 9999"
       @click.outside="$el.closest('li').style.zIndex = 0"
       @close.stop="$el.closest('li').style.zIndex = 0" type="button">
  • @click=”$el.closest(‘li’).style.zIndex = 9999″
    • @clickはAlpine.jsのclickイベントを簡単に実装するもの。
    • $elはDOMノードで自分を取得するもの。
    • closestはJSのメソッド。今回は一番近い親のliを検出している。
    • styleは要素のインラインスタイルを取得するもの。取得するのがメインだが、そのあとにスタイル名を入れることで追加もできる。
    • zIndex = 9999は、極端に大きい数字にしているだけ。
  • @click.outside
    • outsideは、対象のコンテンツ以外をクリックしたら発動するもの。
      今回はz-indexを0にしている。
  • @click.stop

とりあえず、こんな感じで実装し

いい感じになった。

ペジネーションのカスタム

恐らくBootstrapを消したのでpaginationがブラウザ設定対応になってしまっている。

要するに、ダークモードに対応してしまっている。
私としてはとりあえず一貫したデザインにしたいのでダークモード対応はしたくない。

つまり、ペジネーションのCSSを弄りたい。
そんな弄る方法のメモ。

日本語ドキュメント

ペジネーションのHTMLを弄る為に
php artisan vendor:publish –tag=laravel-pagination
でテンプレートを公開する。

公開すると以下のように沢山viewファイルが出力される。

通常のページネーションで使われているのは「tailwind.blade.php」で、カーソルペジネーションや画面幅が小さいときに使われているのが「simple-tailwind.blade.php」なので、これを弄ってしまえば変更できる。

tailwind.blade.phpのdarkが付いてるCSSを消したら

のようになった。

また、もし他のテンプレートを使いたいときはAppServiceProviderで

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Paginator::defaultView('view-name');


        Paginator::defaultSimpleView('view-name');
    }
}

のように設定し、デフォルトごと変えてしまうか、
{{ $paginator->links(‘view.name’) }}
のように個別でlinks()の中に使いたいviewを入れるか。

デジタル庁のデザインシステムってなんだ

QiitaやらTwitterやらで話題のデジタル庁。
デザインシステムというものを作っているので調べてみる。

デジタル庁のサイトにある文をそのまま引用すると

デジタル庁サービスデザインユニットでは、一貫したデザインや操作性でウェブサイトやアプリを提供するための仕組み「デザインシステム」の構築に取り組んでいます。どなたでも構築中のデザインシステムのデザインデータを閲覧することができます。

つまり、デジタル庁独自のデザインシステムを作り、その詳細を公開してくれている。
このデザインシステムというのは特異なものではなく、調べてみたところWebデザインを考えるときに使われる概念みたい。

デザインの定義を行い、その定義に従ってデザインするのがデザインシステムなのかな。

デザインシステムを定めたい

今の私のサイトはQiita、Zenn、note、SoundCloud、piaproを参考にその場その場でCSSやHTMLを構成している。

良いのか悪いのかわからないが、明確なルールがなく、一貫性がない。
一貫性が無いので、毎回何かを実装しようとするたびにどう実装しようか悩んでしまう。

どうにかしたいとは思っていたので、ちょうどよいかもしれない。

TailwindCSSを学びたい

デザインシステムについて調べていたら、「Tailwind CSSでデザインシステムを構築する」というページに辿り着いた。

このサイトの内容がめちゃくちゃ面白い。
今まで、ただCSSを生成するためのツールだったTailwindCSSに興味を持たされた。

こちら、「Tailwind CSS実践入門」という本の内容を一部もってきたものらしいのでちょっとこの本を読んでTailwindを学ぶ。

本の1章まで読んで思ったTailwindCSSのメリット

本書に書いてあるTailwindCSSのメリットは以下の通り、

  1. クラス名を考える必要がない
  2. HTMLとCSSを行き来する必要がなくなる
  3. 影響範囲の明確化

この3つもその通りだと思うが、TailwindCSSからCSSに入った私としては初心者でもTailwindCSSが定義したデザインの型にある程度準拠してCSSを定義できるのが良いところだと感じた。

私は今までp-1が4pxの空白を取ることを知らなかったし、フォントサイズや色の選択肢があえて少なくなっているのも知らなかった。
でも、そうしたデザインの型、デザインシステム的なものを認知せずに利用でき、ある程度一貫性のあるものにできるのは素晴らしいなと思った。

また、さっき上に挙げた3つのメリットはインラインCSSでも享受できると思う。
そんなインラインCSSとの大きな違いはこの標準化にあると感じた。

2章を読んでおもったこと

インラインCSSとの違いとして、前述した標準化以外にもヴァリアントがあった。

ヴァリアントは「lg:flex」とか「hover:text-cyan-500」とかの接頭につく条件付きクラスのこと。
これはインラインCSSでは実装できないみたい。

次に、デザインシステムは3層構造になっているとある。
より普遍的なものから

  • デザイントークン
    デザインの原則。CSSに限らない。
  • ユーティリティ
    デザイントークンをCSSに落とし込んだらどうなるか。
  • コンポーネント
    ユーティリティを集めて作成されたもの。

デフォルトで空白の単位が4pxなことや、フォントサイズ、色の選択が限られていることはいってしまえばTailwindCSSのデザイントークン、ユーティリティが既に定められているということ。

であれば、デザインシステムをわざわざ考えずにTailwindCSSのデザインシステムの意図を理解し、必要な時だけ自分のデザイントークンを定義して適用していくのがいいのかなと思った。

4章の序盤を読み、ESlintとそのプラグインを知る

Prettierというコードフォーマッタの説明があったんだけど、どうやらTailwindCSSのクラスをソートしてくれる機能があるらしい。

めっちゃ便利じゃん! と思って調べていったらESlintもPrettierもBladeへの対応が微妙だった。

いろいろ試した結果、PhpStormでBladeを扱う場合、Tailwind Formatterで十分というか、それくらいしか選択肢がなさそう。

このTaikwind Formatterは保存時に整形をしてくれないんだよね。そこが不満点。

BladeとPhpStormの組み合わせだから選択肢がないだけで、VSCodeであればESlintだったらできるかもしれない。

Prettierはそもそもパース的にBladeに対応してなくて、ESlintはパースを変えればBladeに対応してるんだけど、PhpStormの保存時のコード整形に対応してない? って感じ。

CKEditorが公開しているCSSと、CSS衝突時の優先度について

いろいろ見てたらCKEditorが編集時のCSSを公開してくれていることに気づいた。

記事の親要素にclass=”ck-content”を追加してこのCSSを適用してあげればCKEditorの編集時と同じにはなるんだけど、編集時の見た目に納得行かないところはどちらにしろあるので、今まで作ったCSSをこのCSSと同時にかけようと思う。

そこで問題になるのがCSS衝突時の優先度。

参考になったのは「CSS解説 -カスケード (スタイル指定が重複した場合)-」というサイト。
このサイトはCSS2の情報なんだけど、MDNを見たところ大体あってそう。

簡潔にまとめると以下の順で処理される。
上から順に処理されていき、どこかで優先度が決まればそれ以上は処理されないイメージ。

  1. 出所による優先度
    HTML文書作成者 > ユーザーのカスタム > ブラウザの初期値
    他にも、styleシートよりインラインの方が優先度高いとかある。
  2. 詳細度による優先度
    CSSを適用したとき、適用の仕方によって優先度が変わるシステム。
    例えば、「li {}」よりも「ul li {}」のように詳細に指定するとそちらが優先される。
    詳細度についてはMDNを見るのが良いと思うが、理解はそう簡単ではなさそう。
  3. 位置による優先度
    プログラミングと同じで、後に処理されればされるほど優先度が高くなる。
    同じCSSファイル内だったら下に書いてある方が優先されるし、styleを読み込むときも最後に読み込んだ方が優先される。

また、!importantを使うと優先度が上のものより高くなるが、逆に言えば無秩序なものともいえるので非推奨とするところもあるみたい。

つまり、CKEditorのCSSより優先度を上げるにはスタイルシートの読み込み順を変えてあげればいい。

実際
@vite([“resources/css/content-styles.css”,”resources/css/article/show.css”])
こんな感じにすれば後にあるshow.cssが優先された。

優先されるといっても、変えたいCSSを私が指定しないと上書きされないので注意。

4章のz-indexを見てからの扱い

筆者の主張を簡潔にすると、

z-indexは扱いが複雑になりがち。
TailwindCSSは-50~50の10刻みで11段階のみを扱えるだけましだが、それでもややこしくなる。
今はz-index以外にも<dialog>やReactを使えばz-indexを使わなくても順序は変えられるので、思い切ってz-indexは0,1,infの3段階だけにしよう!

というのが筆者の主張。
これには私も大賛成! というか、dialog要素がかなり便利そう。

一回z-indexの実装を行ってからdialog要素も見ていく。

module.exports = {
   theme: {
       zIndex: {
           0: 0,
           1: 1,
           inf: "calc(infinity)",
       }
   },

このように実装すれば、z-0やz-1、-z-1、z-infが使えるようになる。
しかし、extendで実装していないので他のz-10などは使えなくなるので注意

dialog要素でモーダルを書き直す

私が実装できたと思ってたモーダル、モーダルとしての機能を満たしてなかったみたい。

モーダルダイアログの未来はdialog要素で幸せになるか」というブログによると、モーダルの要件は以下の通り

  1. バックドロップ(背景)とダイアログボックスで構成されている
  2. 最前面のレイヤーに描画される
  3. ユーザーの操作で閉じる事ができる
  4. 背景ドキュメントへのアクセスをブロックする(クリック・選択・フォーカスはバックドロップを貫通しない)
  5. 背景ドキュメントのスクロールをロックする
  6. バックドロップをクリックすることで閉じることができる

この中で、私が満たせていなかった要素は2番4番5番の3つ。

めっちゃあるなぁ。

実際に見てみるとこんな感じで全然最前面じゃないし、普通にスクロールできるし、tabを使えば背景の要素にアクセスできてしまう。

最前面じゃないのは普通に見づらいのでdialogを使って最前面に表示されるよう改善していく。

最終的にできたのが以下のようなもの。
JSはAlpine.jsを使っている。

<div class="mt-3 flex justify-center"
    x-data="{
       openDialog() {
           this.$refs.report.showModal();
           document.body.classList.add('overflow-hidden');
       },
       closeDialog() {
           this.$refs.report.close();
           document.body.classList.remove('overflow-hidden');
       }
    }">
   {{-- モーダルを開くボタン --}}
   <button @click="openDialog">
       <x-icons.flag/>
   </button>
   {{-- モーダルの内容 --}}
<dialog x-ref="report" class="fixed inset-0 overflow-y-auto" @click="closeDialog">
       {{--モーダルの中身--}}
       <div class="w-96 flex min-h-screen items-center justify-center" @click.stop>
           <div>
               <button
                   class="mt-4 mr-3 h-10 rounded-md border-2 bg-white px-4 font-bold text-black border-white-500 hover:border-gray-600 focus:outline-none"
                   type="button"
                   @click="closeDialog">キャンセル
               </button>
           </div>
       </div>
   </dialog>
</div>

これを使ってみると

いい感じ。

モーダル画面の条件も以下のように達成している

  1. バックドロップ(背景)とダイアログボックスで構成されている
    dialogタグは勝手に::backdropを付けてくれるので達成。
  2. 最前面のレイヤーに描画される
    dialogタグ的に、最前面に出るようになっている。
  3. ユーザーの操作で閉じる事ができる
    閉じるボタンをも実装済み。
  4. 背景ドキュメントへのアクセスをブロックする(クリック・選択・フォーカスはバックドロップを貫通しない)
    dialogタグを使えば大丈夫
  5. 背景ドキュメントのスクロールをロックする
    document.body.classList.add(‘overflow-hidden’);でbodyのスクロールをできなくしている。
  6. バックドロップをクリックすることで閉じることができる
    dialog自体にcoloseDialogをつけており、その子要素を触ると閉じるようになっている。
    そのままdialogの内部をクリックするだけで閉じちゃうので、閉じてほしくない子要素には@click.stopを付けイベントの伝播を止めている。

また、dialogを使えばescキーで閉じれるようにもなるっぽい。一石四鳥だね。

少なくとも私はdialogで幸せになれた。

JSを使わずに、特定のラジオボタンを選択したときに注意事項を表示する

具体的な例は以下の通り

著作権違反をクリックしたときだけ警告文が出るようにするのが目的。

今までAlpine.jsを使ってどれを選択しているのかで表示・非表示を分けてたんだけど、TailwindCSSのpeerがめっちゃ便利なことに気づいた。

監視したい兄弟の対象にclass=”peer”をつけて、それに応じて変化させたい監視対象より後の兄弟要素に「peer-{状態}」:のモディファイアを付ける。

そうすれば、監視対象がその状態になったときにCSSが反映されるという仕組み。

コードで見ると以下のような感じ。

Tailwind Play
An advanced online playground  
<form action="">
  <input type="radio" id="1" name="number" />
  <label for="1">1</label>

  <input type="radio" id="2" name="number" class="peer" />
  <label for="2">2</label>
  <div class="hidden peer-checked:block">警告</div>
</form>

ナンバー2がラジオで選択されると、それにつられてdivがhiddenからblockになり警告という文字が表示されるという感じ。

JS使えるならJSでもいいと思うんだけど、CSSの方が簡潔にはなる。

また、親要素の状態によって子要素を変えたい場合はgroupでもできるみたい。

Arbitrary Values (px-[3px]などの)表記を使うのは慎重に

TailwindCSSではpx-[3px]のように大括弧の中に任意の値を入れて使うことができる。

これを知ったときの私は、「めっちゃ便利じゃん!」と思って結構多用しまくっちゃったんだけど、デザインシステムという概念から行くと出来るだけ既存のユーティリティクラスをそのまま使うのが吉。

Arbitrary Valuesを使うこと自体は悪くないんだけど、既存のデザインシステムを跳ね除ける理由は必要。

気になる人は「class=([“‘]).*?\[.*?].*?\1」のような正規表現で[]が使われているTailwindCSSを見つけて気ままに修正していこう。

第9章を読んで、デザインシステムを考える

第9章ではデザインシステムの定義を

デザイナーと開発者がスタイリングにおいて同じ用語を用いること、およびその状態を達成するための一連のしくみ

工藤 智祥. Tailwind CSS実践入門 エンジニア選書 (p.572). 株式会社技術評論社. Kindle 版. 

としている。

この部分の文書は「Tailwind CSSでデザインシステムを構築する[前編]  ~「契約」としてのデザインシステム | gihyo.jp」でも読むことができる。

ともすれば、デザイナーがいない個人開発ではデザインシステムは必要ないということになってしまいそうだが、ここまで読んでしまったのでどうにかこじ付けたい。

Webデザインがわからない私としては以下のような需要、要望がある

  • デザインで迷いたくない
  • デザインに統一感が欲しい
  • 見やすい、分かりやすい、使いやすいデザインにしたい

ちょっと抽象的に言えば

  • デザインに意図があり、
  • そのデザインに準拠していることがわかりやすい

のが大事。

と言ってもデザインがマジでわからないので、デザインに意図を持たせるのが難しい。

今からデザインを学びたい気持ちをぐっっっと抑えて、重要そうなデザイントークンだけ軽く定義したい。

とりあえずTailwindCSSの実践入門は必要そうなとこだけ読んだのでデザイントークンの話へ。

オリジナルのデザインシステムをちょっとだけ定める

デザインはコンポーネントから作るトップダウンでなく、細かい定義から行うボトムアップから行うと良さそう。

じゃあどこまで細かく定義するんだってところなんだけど、重要そうなデザイントークンから考えたい。

私の認識するデザイントークンはプラットフォームによらないデザインの定義のこと。

例えば、重音テトの髪の色は赤褐色と明確に定義されている。
具体的かはともかく、これは色という概念があるどんなプラットフォームにも落とし込める表現であり、究極のデザイントークンと言える。

更にこれを#e35577と定義すればデジタル的なデザイントークンになり、teto-500を#e35577とTailwindCSSで定義すればユーティリティクラスとなる。

このデザイントークンは複数のプラットフォームを利用するときに便利になる。
figmaでWebサイトをデザインし、Webサイトに落とし込みたいという時、共通のデザイントークンを定義しておくと意思疎通もしやすく、齟齬も生じにくいということらしい。

であれば、TailwindCSSでしかデザインをしない私の場合、TailwindCSSのユーティリティクラス≒デザイントークンと言っても問題はなさそうなので、いきなりTailwindCSSのユーティリティクラス定義を行ってもよさそう。

しかし、初音ミク、重音テトの色は今後もWeb制作のみならず利用しそうなので、デジタル的なデザイントークンは定義したい。

デザイントークンで何を定めるか

とりあえず、デジタル庁のデザインシステム、TailwindCSSで最初から定められている値を主な材料に、以下を定めようと思う

  • キーカラー、共通カラー
  • 赤、緑、青、黄色
  • レスポンシブのブレイクポイント
  • スペーシングについて
  • 文字について

の計5つについて簡単に定める。
ここで大事なのは、CSSは後からでも変えられるからやってみようの精神だと私は思う。

また、デザインについてなんもわからんので、出来るだけシンプルにをコンセプトにしたい。

キーカラー、共通カラーを定める

色の知識は殆どないけど、一つ知っていることは、色の認識は音の認識と同じように人によって差がある。

その認識の差を出さない為、コントラストや文字の大きさ、読み上げ機能対応などの対策があるみたい。

こういうのを、アクセスできる能力を上昇させるという意味でアクセシビリティの向上と言ったりするけど、W3Cが具体的にこのアクセシビリティについて定めてくれているので参考にしたい。

テキストのコントラストについてはAA(最低限)AAA(最高)の2つが定められている。

これを考慮し、17ポイント以下の文字または13ポイント以下の太字で色を扱う際はAA(最低限)の4.5:1は必ず守って使っていく。
もし、18ポイントまたは14ポイント以上の太字なら3:1を守っていく

コントラスト比の計算はAdobeのコントラストアナライザーを利用したり、Googleの開発者ツールから見れる。

キーカラー

キーカラーは大事な初音ミクと重音テトの色から決めたい。

といっても、2色のキーカラーを扱うほどデザインはわからないので、キーカラーは初音ミクから採り、重音テトカラーは危険系の色として使う。

私にとって初音ミク、重音テトは何色か

初音ミクと重音テトをデジタル的な色、つまり、16進数のカラーコードで色を定めたい。

初音ミク、重音テト共に明確なカラーコードは定義されていないと言ってよい。
VOCALOID2版初音ミクの公式サイトでは色についての明言はない。

また、重音テトの場合赤褐色というデジタルで具体的に定まらない色になっている。

これは、初音ミクの場合は最低限のプロフィールという前提があり、重音テトは>>10で赤褐色と言ってしまったので、カラーコードの定義はないという感じ。

一応、piapro.netの初音ミクの欄を見るとブルーグリーンと説明されており、

HTMLを覗いてみるとインラインで#39C5BBと設定されている。

しかし、初音ミクの色=#39C5BBというわけではなく、初音ミクの色の中に#39C5BBという色もあるだけという認識が良いと思う。

これらを確認したうえで、私にとって初音ミクと重音テトの色で大事なのは相性だと思うので、相性が良い色がいいかなぁ。

つまり、ブルーグリーンと赤褐色の相性の良い2色を選べばよい。

選び方なんだけど、色の知識を持っているわけではないのでTailwindCSSにあるものから選ぶ。

TailwindCSSで既に定義されているものであれば、ある程度相性は良いだろうという慢心。

Google翻訳した文にもこうあるし、間違っては無いと思う。

初音ミクはここら辺

重音テトはここらへん

この中からTailwindCSSを実際に触っていろいろ試したんだけど、teal系が初音ミク、red系が重音テトで落ち着いた。

重音テトの赤褐色を調べると以下のような赤茶っぽい色が出てくるんだけど、流石にこの色は重音テトっぽくないので、事実上の重音テトの色である赤っぽい色を選んだ。

rose系の色はピンクぽくなるのでやっぱりredかなという気持ち。
初音ミクはさすがにteal系だった。昔っぽい初音ミクはemerald系だとは思う。

ちなみにnavバーのemeraldとtealの比較は以下。上の画像がtealで背景は#FFFFFF。

emeraldも悪くはないけど、なんか不安な色だと感じてしまうのでtealにする。

また、カラースケールを自作したい場合はTailwindCSSのgenerating-colorsを見ると幸せになれそう。

色をTailwindCSSで定義する

初音ミクはteal系、重音テトはred系にした。
これは暫定的なものなので、使う時はteal-500とかではなくmiku-500のようにして使いたい。
そうすればもし後から色を変えるときにteal系のカラーパレットを壊す必要がなくなる。あと、使う時miku-500とかteto-500で使いたい。というか正直、その気持ちが大きい。

TailwindCSSのユーティリティクラスの定義はtailwind.config.jsで行える。

extendの中に定義することで、既存のクラスを維持したまま追加できる。

module.exports = {
   theme: {
       extend: {
           colors: {
               // 中身はtealと同じ
               "miku": {
                   50:
                       "#f0fdfa",
                   100:
                       "#ccfbf1",
                   200:
                       "#99f6e4",
                   300:
                       "#5eead4",
                   400:
                       "#2dd4bf",
                   500:
                       "#14b8a6",
                   600:
                       "#0d9488",
                   700:
                       "#0f766e",
                   800:
                       "#115e59",
                   900:
                       "#134e4a",
                   950:
                       "#042f2e"
               },
               // 中身はredと同じ
               "teto": {
                   50:
                       "#fef2f2",
                   100:
                       "#fee2e2",
                   200:
                       "#fecaca",
                   300:
                       "#fca5a5",
                   400:
                       "#f87171",
                   500:
                       "#ef4444",
                   600:
                       "#dc2626",
                   700:
                       "#b91c1c",
                   800:
                       "#991b1b",
                   900:
                       "#7f1d1d",
                   950:
                       "#450a0a"
               }
           }
       },
   },
};

オリジナルのカラースケールを作ってみる

UIColorsを使うと1つの色からカラースケールを作ってくれるみたい。
カラースケールって、音楽理論のスケールと似てるね。

piaproにあった#39c5bbを入れてみると

こんな感じで作ってくれた。

重音テトの15周年バッジの色を採ってUIColorsに入れてみると

こんな感じ。

#39C5BBは暗めの色なんだね。

当たり前っちゃ当たり前なんだけど、こんな感じで出力までしてくれるのがありがたい。

このpiapro初音ミク色はjavaという名前なのかな?

色合いはこんな感じ。
上がTailwindCSSのtealで、下がpiapro色

下の方が色が濃くて安定感はあるけど、tealを見慣れてしまったのでteal系でいこうかな。
色もこっちの方が緑っぽいし。

テトさんの色を赤色と交換してみた。
上が元の色で下がテトさんのバッジから作った色。

なんか渋いけど、とてもテトさんっぽくて好き。

しかし、既存のカラースケールと合わせると色の系統が合ってなくてちょっともやもやする。

ちょっと試してみたくなったのでオリジナルのカラースケールを作ったりしたけど、とりあえずはTailwindCSSの既存のもので良いね。

共通カラー

共通カラーという言葉はデジタル庁のデザインシステムで出てきたので使っている。

デジタル庁の説明文をそのまま引用すると

共通カラーは、白から黒のグレーの階調(ニュートラルカラー)をベースに構成され、ページの共通の要素、テキスト、境界線、背景、またUIの構成パーツなどに使用されます。

とのこと。

つまり、TailwindCSSでいう

らへんの部分。

なぜこれをわざわざ挙げたかというと

  1. 黒を表現したいとき#000000はあまり使わないらしいこと
  2. なんかslateとかgrayって青くない?
  3. 一貫した共通カラーにしたい

という気持ちから。

私、文字色に#000000を当然のように設定してたんだけど、#ffffffと#000000ってコントラストの差がありすぎてみにくいらしい。

他のサイトを見ると明度10%前後に設定されているのが多いっぽい。
詳しくは「ウェブサイトの本文色の黒は真っ黒(#000000)でないことが多い」って本当?検証してみたがおすすめ。

また、よく名前のわかりやすさからgrayを使ってたんだけど、grayで暗い色を出そうとすると若干青みがかかっちゃったりして困ってた。

だからここで一回自分の共通カラーを定義したい。

定義したいといってもやることはキーカラーと同じで、TailwindCSSから選んで独自の名前にするだけ。

私が選んだ共通カラーはNeutral

RGBがそれぞれ均等に配置されたもの。
これはSoundCloudも均等に配置された黒白をつかっていたので、とりあえず。

スケールネームはデジタル庁と同じsumiにする。
共通カラーを使う時はsumiで使い、後から変えたければ変える方針へ。

また、本文系の黒文字はsumi-900を、目立たせたいものはsumi-950を使う。

赤、緑、青、黄色の定義

赤、緑、青、黄色系の色を使いたいとき、どの色を使うか悩むと困るのでどのカラースケールを使うか決める。
ここはそこまで使うものではないので、TailwindCSSのカラースケールをそのままの名前で扱う。

一応、それぞれの利用用途は


  • 危険色、目立ちたいとき

  • 成功色

  • リンクや特殊なボタンに使いたい色
  • 黄色
    警告色

赤はTetoで既に利用しているので、緑、青、黄色の定義。

青と緑は正直こだわりはないんだけど、黄色はYellowだと黄色すぎてなんか落ち着かないのでちょっと濃いAmberを使いたい。

実際に使ったところを見てみると

私は下のAmberの方が落ち着く。

その為、最終的に

のような感じに。
RedはTetoと被るので代わりにTetoを利用する。

最後に背景や文字のデフォルト色を設定して完了。
色だけで3日くらいかかった。やばい。

レスポンスのブレイクポイントについて

ブレイクポイントというのは、HTMLのレイアウトが横幅に応じて変わるポイント。
スイッチポイントとかの方がわかりやすい気がするんだけど、巷ではブレイクポイントと呼ばれている。

TailwindCSSでのブレイクポイントはモバイルファーストとなっている。
モバイルの画面をデフォで考え、PCの画面は特殊例として考えてくれということらしい。

つまり、lg:w-[300px]とすると画面の横幅がlg未満の時はw-[300px]は適用されず、lg以上の時に初めてw-[300px]が適用されるということ。

私はPCファーストだと思っていたので、これを知って今までの?が解決した。

そんで、本題のブレイクポイントについてなんだけど、どうしよう。
例えば、TailwindCSSでは5つのブレイクポイントを、Bootstrapでは6つのブレイクポイントを設けている。

TailwindCSS

Bootstrap

私は5つのTailwindCSSでさえ迷ってしまうことがあるので選択肢をもっと少なくしたい。

デジタル庁のデザインシステムを見てみると、768px未満か以上かの1つのブレイクポイントしか設けていなかった。

もうシンプルなのでこれで全然良いと思うんだけど、意図や意味がわからないのでちょっと探る。

768pxの意味

軽く調べてみた感じ、これはタブレット端末でシェアの最も高いiPadのピクセル数みたい。
iPadを縦持ちにした時の横幅が768pxなので、そこで区切ってるんだね。

つまり、iPad縦持ち時にPCと同じ表示にしたいなら768px以上をブレイクポイントに、iPad縦持ち時にスマホと同じ表示にしたいなら769px以上をブレイクポイントにするとよさげ。

しかし、2024年現在ではiPadの解像度や比率も変わってきていて768pxにすればiPad全てを網羅出来るわけではないので注意。

iPhone/iPad/Apple Watch解像度(画面サイズ)早見表

超高解像度のデバイスはどうなるか

最近だと横幅の広いiPadや4Kなどの超高解像度を持つディスプレイも出てきている。

2732*2048の解像度に対し、そのままWebサイトを表示させると文字などが小さくなってしまいそうだが、このようなデバイスの扱いはどうなるのだろうか。

このようなデバイスは設定されたデバイスピクセル比に応じ、CSSピクセルへ変換されるみたい。
例えば、上で上げたiPad Pro(第6世代)はデバイスピクセル比が2なので、2*2のピクセルが1CSSピクセルへ変換され扱われるという感じ。

すなわち、2732*2048pxが1366*1024pxの画面としてブラウザ上では処理される。

詳しくは「【cssピクセル】レスポンシブサイトを作る時のピクセルの考え方。適切なサイズでWebサイトを制作しよう!」がわかりやすかった。

768pxをブレイクポイントにして問題なさそうか

正直、デバイスの多様化が進む今、最適解は無さそう。

私のサイトはDTMをしながら閲覧されることが予想されるので、1920の画面を半分にした時を考慮したい。

例えば、768pxただ1つをブレイクポイントにしてしまうと以下のように200pxの広告や目次を含むときなどに、かなり見づらくなってしまう。

1920pxの半分である960pxの時でも当然見辛い。

レイアウトを変えれば解決はするっちゃするんだけど、マネタイズの為に広告スペースが欲しいのと、右に目次があると個人的に便利なのでこのレイアウトでまずは行きたい。

以上のことを考慮すると768pxのみの選択は良くなさそう。

768pxと1024pxの合計2つにブレイクポイントを持たせる

いろいろ考え、以下のようにすると良いかなと思った。

基本は768pxのブレイクポイントだけで運用し、記事閲覧画面などの大きめの横幅の固定pxが存在する画面のみ1024pxのブレイクポイントも扱うという感じ。

扱いとしては以下のような感じ

  • 768px未満
    モバイル端末
  • 768px以上
    タブレット~PC
  • 768px以上、1024px未満(基本使わない)
    タブレット、PCでブラウザを半分くらいで閲覧したとき

768pxと1024pxの理由はTailwindCSSでデフォである数値だからというのが一番大きい。
強いて言えば、768pxはタブレット縦持ちのよくある横幅、1024pxはタブレットの横持ちでよくある横幅かつPCの最低横幅をほぼ網羅できる値。

とりあえずこれで運用してみて、問題がありそうなら後で修正する。

TailwindCSSのブレイクポイントをカスタムする

一応初期値で768pxはmd、1024pxはlgで利用できるが他の値を使えなくしたいのと、名前をもう少しわかりやすくしたい。

変更の仕方はTailwindのドキュメントを見るのが良い。

私の場合、名前はデフォのまま以下のようにした。

screens: {
   "md": "768px",
   // => @media (min-width: 768px) { ... }


   'lg': '1024px',
   // => @media (min-width: 1024px) { ... }
}

上書き登録にしたので、他のsmやxlが使えなくなっただけ。
最初は数字をそのまま名前にしたり、tabletやtbやpcという名前も考えたんだけど、わかりやすさ、汎用性を考えてmediumやlargeの頭文字をとるのが良いなぁと。

これだったら既存のコードもmd,lgならそのまま使えるからね。

スペーシングについて

TailwindCSSのドキュメントは「Customizing Spacing – Tailwind CSS

TailwindCSSではpaddingやmarginだけでなく、width、minWidth、maxWidth、height、minHeight、maxHeight、gap、inset、space、translate、scrollMargin、scrollPaddingで同じspacingという値を使って間隔を決めている。

デフォルトのspacingでは以下のようにある程度飛び飛びながらかなり細かく値が定められている。

NameSizePixels
00px0px
px1px1px
0.50.125rem2px
10.25rem4px
1.50.375rem6px
20.5rem8px
2.50.625rem10px
30.75rem12px
3.50.875rem14px
41rem16px
51.25rem20px
61.5rem24px
71.75rem28px
82rem32px
92.25rem36px
102.5rem40px
112.75rem44px
123rem48px
143.5rem56px
164rem64px
205rem80px
246rem96px
287rem112px
328rem128px
369rem144px
4010rem160px
4411rem176px
4812rem192px
5213rem208px
5614rem224px
6015rem240px
6416rem256px
7218rem288px
8020rem320px
9624rem384px

これだけ細かく決められると、どれを選ぶべきか悩んでしまうので出来るだけシンプルにしたい。

つまり、もっと選択肢を少なくしたい。

TailwindCSS実践入門では一般化されたフィボナッチ数列の値をspacingに定めたこともあるらしく、この方法はとても良さそうなので採用したい。

しかし、既にviewは殆どできており、これを今から変更するのはかなり面倒という気持ちが高い。

といっても、後になればなるほど面倒になるので一回フィボナッチ数列spacingを実践して、修正までにどれくらい時間かかるかやってみる。

……

よし、恐らく修正できた。

spacingは以下のようにした。

spacing: {
   "0": '0px',
   "4": '4px',
   "8": '8px',
   "16": '16px',
   "24": '24px',
   "40": '40px',
   "64": '64px',
   "104": '104px',
   "168": '168px',
   "full": '100%',
}

修正にかかった時間は3時間くらい。思ってたくらいの時間。

w-104よりw-40が優先される – TailwindCSSの優先度

spacingを弄っていて遭遇した問題、というか私のミスを一つ。

私のコードにはコンポーネントからサイズを指定して画像を表示する箇所がある。
コンポーネントにデフォルトでサイズが指定されていて、mergeで更にサイズを指定して使っていた。
spacingを弄る前のコードが以下で、デフォでw-10 h-10が付いておりmergeで後からw-24 h-24を追加している。

<img src="url"
    class="w-10 h-10 rounded-full object-cover border-none bg-gray-200 w-24 h-24 mr-3">
</img>

spacingを弄った後のコードが以下で、デフォでw-40 h-40が付いており、mergeでh-104 w-104を追加している。

<img src="url"
    class="w-40 h-40 rounded-full object-cover border-none bg-sumi-200 mr-16 h-104 w-104">
</img>

spacingを弄る前のコードでは普通にw-24 h-24、つまり96pxの正方形が表示されていた。
この例以外でアイコンなどでも同じようなことをやっており、やっぱりmergeした方が優先され大きさが変わっていた。

だから、ユーティリティクラスは後に付いていればいるほど優先されると勝手に思っていた。

しかし、今回

class="w-40 h-40 rounded-full object-cover border-none bg-sumi-200 mr-16 h-104 w-104"

が付いたimgはw-40 h-40が優先された。

これ、ソースが見つからなかったので憶測になっちゃうんだけど、w-40が優先される理由は辞書順でクラスを宣言してるからっぽい。

生成されたCSSファイルを見てみたんだけど、

.h-104 {
    height: 104px;
  }
 
  .h-16 {
    height: 16px;
  }
 
  .h-24 {
    height: 24px;
  }
 
  .h-40 {
    height: 40px;
  }
 
  .h-64 {
    height: 64px;
  }
 
  .h-8 {
    height: 8px;
  }

辞書順にクラスが生成されているのがわかると思う。
生成されたもの全体を見ると、これより上にz-indexとかがあるので辞書順ではないっぽいんだけど、heightの中での生成は辞書順っていうのが正確だろうか。

h-40の方が後に宣言されているので、そりゃh-40が優先されるのも納得。
そもそもユーティリティクラスという特性上、被ったらCSSファイルの中で後に生成されたものの方が優先されるのは当然だった。

この回避策なんだけど、spacingの名前を040みたいにして、コンポーネントのデフォルトサイズを考えられる最小値にしておくとか?

でもどちらにしろクラスの生成順とかは変わるかもしれないし、ユーティリティクラスの特性上、後に宣言された方が優先されるのはしょうがないので、そもそもmergeで値を被らせるのが良くない。

恐らく、大きさとかが不均一なら使う度に指定するのが丸いしTailwindCSSのコンセプトに合ってる。

文字について

ここは軽く触れて次に行きたいが、記事を提供するサイトなので大事なところではある。

文字にかかわる要素

文字に関わる要素として考えられるのは以下の通り。

  • 大きさ
  • 行送り(line-height)
  • 太さ
  • フォント

文字の大きさ

TailwindCSSでは以下のようにtext-[大きさ]で大きさを指定できる。

text-baseとやるとfont-sizeとline-heightの計2つのCSSが適用される。

ここで気になるのはremという単位とline-heightの2つ。

remとは何か

なんか時々見たことあるけど、何かわからなかったrem。
mdnによると、remはhtmlのfont-sizeを示すもの。

つまり、htmlのfont-sizeから何倍ですかという値。

多くのブラウザでhtmlの規定値は16pxらしいので、font-size: 2rem;とすれば32pxの大きさになる。

remのメリットはいろいろあるっぽいけど、私的に一番のメリットはアクセシビリティなのかなと思った。
ユーザーがhtmlの値を16pxじゃなく20pxにすれば全体的に大きい文字で見れるのはいいね。

行送り(line-height)とは何か

line-height – CSS: カスケーディングスタイルシート | MDN

line-heightはその文字の大きさ+次の文字までの距離のこと。
つまり、text-baseでは

text-basefont-size: 1rem; /* 16px */line-height: 1.5rem; /* 24px */

と定義されているので、文字の上下には1.5rem-1remの0.5remのスペースができる。
これは上下で分割されるが、均等に分かれるかはフォントの種類によるらしい。

もしtext-baseの文字が2行続くとその文字間の距離は0.5remになる。
もし、スペースを厳密に計算したい場合、文字間のスペースだけでなく、最初の行の上と最後の行の下にできるline-heightも計算に入れる必要があるので注意。

太さ

TailwindCSSでは、fontの太さは以下のようにデフォルトで9段階の指定ができる。

これ、英語圏だと9段階全て対応していることは多いらしいんだけど、日本だと日本語フォントがそもそもnormal(400)とBold(700)にしか対応してないとかがあるみたい。

単純化の為にもこの2つだけを使うのが良さそう。
デジタル庁のデザインシステムでもボールドを利用するときはBold(700)のみにしている。

TailwindCSSの設定ごと変えたいなら以下

fontWeight: {
   "normal": "400",
   "bold": "700",
}

フォント

フォントも少し触る。

デジタル庁のデザインシステムを見るとフォントに「Noto Sans Japanese」というのを使っているのがわかる。

Web開発でフォントは意識したことがなかった。
わからない単語が多いので、単語から理解していく。

フォントファミリーとは

調べていると出てくる「フォントファミリー」という言葉。
モリサワのファミリーについて書いてある記事を見てみると、

同じコンセプトで統一された骨格とエレメントから、ウエイトを段階的に変えて作られた書体のグループのこと。

https://www.morisawa.co.jp/culture/dictionary/1936

とのこと。

つまり、デザインは同じだけど違う太さを持ったフォント群のことを言うのかな。

Serifとは

AdobeによるとSerifは線の端にセリフという装飾がついたものを指すらしい。

また、フランス語で無いを指す「Sans」を使うSans Serifというのもある。
これは、Serifでは無いということ。

TailwindCSSでSerifとSans Serifを見比べてみると

このような感じになる。

Serifがあると手書き感が出るのかな。
Webサイトでは基本的にサンセリフが好まれるっぽい。

Noto Sans Japaneseとは何か

Noto Sans JapaneseはGoogleによって提供されている、表示できない文字を無くすことを目標にしたサンセリフのフォントNoto Sansの日本語版。
Open Font Licenseなので、商用でも自由に利用できるみたい。

ちなみに、NotoはNo Tofuの略らしい。文字化け時の□が豆腐みたいだから。

このNoto Sans Japaneseは日本漢字、ハングル、ラテン、カタカナ、ひらがな、絵文字記号、ボポモフォ、キリル、ギリシャ語に対応している

つまり、中国の漢字とかではない限り基本表示できるはず。

また、見やすさにもこだわっていたり、太さが9種類あるなど、驚異の汎用性でWebサイトでは広く使われているみたい。

実際に、WindowsのChromeのデフォルトフォントと比べてみる。

以下はデフォルトフォント

以下はNoto Sans Japansese

確かに、Noto Sans Japaneseの方がはっきりしていて見やすい気はする。
また、デフォルトフォントより1文字あたりの横幅が広いね。

Noto Sans Japaneseを採用すべきか

正直デザインはわからんので、一旦採用してダメそうなら辞めるでもいいんだけど、他のサイトではどうなってるか、表示速度は遅くなるかをみたい。

表示速度は遅くなるか

計り方がわからないので、Lighthouseのパフォーマンススコアを比較する方法で簡易的にみてみたい。

比較に使うページは記事の閲覧ページ。

デフォルトフォントだと以下のような感じで99点。

フォントを適用すると、以下のような感じで98点。

めちゃくちゃな遅延があるわけではなさそう。

勿論、キャッシュやローカル環境のせいで差があまり無い可能性もあるんだけど、「Google FontsのNoto Sans Japaneseが重い?記述を変えたら速くなるかも!」によると、読み込みが終わるまでデフォルトフォントで表示する「display=swap」や、必要なフォントのみをダウンロードする方式になったりと色々速度対策はされているみたい。

実際にデプロイしてみて、外からアクセスしてみないとはっきり遅くはないとは言えないけど、めちゃくちゃ遅いことはなさそう。

Qiita,Zenn,Noteではどうしているか

参考にしている記事共有サイトを見てみる。

それぞれの記事で適用されているfont-familyを見てみる。

Qiitaの記事ページでは

“YakuHanJPs”, “-apple-system”, “BlinkMacSystemFont”, “Segoe UI”, “Hiragino Sans”, “Hiragino Kaku Gothic ProN”, “Meiryo”, sans-serif;

Zennの記事ページでは

-apple-system, BlinkMacSystemFont, “Hiragino Kaku Gothic ProN”, “Hiragino Sans”, Meiryo, sans-serif, “Segoe UI Emoji”;

Noteの記事ページでは

YakuHanJPs, “Segoe UI”, Arial, Meiryo, sans-serif;

が使われている。

複数指定されているのは、宣言されている順に優先度が高いという感じ。
もし使えなかったら次のフォントを使うというのを繰り返している。

それぞれ気になったとこを見ていく

総称フォント

どのサイトでもsan-serifが引用符なしで使われているのがわかると思う。
これは、引用符の付け忘れではなく、総称フォントを示す意味がある。

総称フォントはfont-familyの抽象的な指示で、san-serifであればsan-serif系統(ゴシック体)のデフォルトフォントが何かしら適用されるという感じ。

ちなみに、普通のフォントファミリーでもスペース等の特殊な文字が無い限りは引用符は無くても問題ないそう。

YakuHanJPs

QiitaとNoteではYakuHanJPsというのが1番最初に採用されている。
これは、括弧や句読点などの約物に出てくるスペースを削って半角にしてくれるYaku Han JPというフォント。

MITライセンスなので自由に使える。

YakuHanJPsだと、sが付いているのでゴシック体の括弧のみを適用しているみたい。

-apple-systemとBlinkMacSystemFont

これはそれぞれ、safariとChromeでSan FranciscoというAppleデフォルトのフォントを適用するためのもの。

MacやiOSを使ってると、そのフォントが適用されるということだね。

結果、どのサイトにもNoto Sansは適用されていなさそうだった。
正直理由はわからないんだけど、見やすさは大事だと思うし他のフォントがわからないのでとりあえずNoto Sansは適用する方針で行こうと思う。

また、YakuHanJPsも読みやすさ向上に繋がりそうなので適用してみる。

コントラスト比4.5:1や3:1が難しいので、メインカラースケールを作る

前にコントラスト比4.5:1または3:1は絶対保つようにすると話したんだけど、これが案外難しい。

例えば、以下のボタン

これはteal-500と#ffffffで構成されたボタン。

フォントは16pxのBoldなので、3:1を守りたいところなんだけど、コントラスト比をみてみると

うーん。足りない。

もし3:1を満たそうとするとteal-600を使う必要があって

暗いなぁ。
アクセシビリティを考えたとき、白文字とのコントラストの相性がtealは悪いっぽいね。

どうにか3前後ギリギリを攻めたいので、オリジナルカラースケールを作ろう。

要望としては、白に対し3:1、4.5:1のコントラストを満たす色がそれぞれ入るカラースケールを作りたい

作り方としては、teal-500とteal-600のRGBから間の値をとったら3に近い値にならないかなという安直な作り方。

間はだいたい#10A698っぽい。

コントラスト比は奇跡の3.03:1、いい感じ。

この色を元にTailwind CSS Color Generatorでcolor-500なら3:1、color-600なら4.5:1になる色を探す。

結果、このカラースケールが良かった。

color-500は3:1、600は4.52:1を保持できた。

teal-500と比べてみよう

上がteal-500で、下が今回作成したカラースケール-500

おお、全然悪くないし何なら良い。

一応、600だと


こっちも悪くないね。素晴らしい。

納得する色になったのでとりあえずこれに決定。

デザインシステムをぱっと確認できるようにしようとしたけど……

一応、簡易的にユーティリティクラスの定義はしたんだけど、パッと確認できないのは良くないので、確認できるようにしたい。

なんか管理ツールとかいろいろあるらしいなぁと思ってたんだけど、私の場合TailwindCSSを基軸に考えたので、設定ファイル見ればいいじゃんと思った。

一応、現時点で以下のように定義したので残しておく。

'800': '#1a5552',
               '900': '#1a4745',
               '950': '#092a29',
           },
           // teto-500の時#ffffffとコントラスト比3:1以上、teto-600の時は4.5:1以上
           "teto": colors.red,
           "sumi": colors.neutral
       }
   },
   // z-indexは「-inf,-1,0,1,inf」の5段階のみで扱う。
   zIndex: {
       0: 0,
       1: 1,
       inf: "calc(infinity)",
   },
   // 基本768pxのブレイクポイント1つで扱っていく。厳しいときだけ1024pxも扱う。
   screens: {
       "md": "768px",
       // => @media (min-width: 768px) { ... }


       'lg': '1024px',
       // => @media (min-width: 1024px) { ... }
   },
   // 一般化されたフィボナッチ数列を採用(詳しくはTailwind CSS 実践入門を参照)
   spacing: {
       "0": '0px',
       "4": '4px',
       "8": '8px',
       "16": '16px',
       "24": '24px',
       "40": '40px',
       "64": '64px',
       "104": '104px',
       "168": '168px',
       "full": '100%',
   },
   fontWeight: {
       "normal": "400",
       "bold": "700",
   }
},

編集ページを考え直す

TailwindCSSでデザインシステムを作ったのでレスポンシブデザインにしようと考えてたんだけど、記事の編集ページの設計が難しい。

要件としては

  • 編集時と閲覧時で差が極力無いように
  • 編集しやすさ大事
  • 下書き保存と公開ボタンは画面に絶対位置で置きたい

この3つ。

最初はQiitaみたいに

上のヘッダーに下書きと公開設定ボタンを設置しようと思ったんだけど、CKEditorはマークダウンでなくウィジウィグなので以下のようになっちゃう

そう、CKEditorのツールバーがヘッダーに隠れるor上に乗る感じになっちゃう。

流石にツールバーが隠れると使えないし、上に乗ると見た目が良くないよね。

対策としては

  • ツールバー方式をやめる
    • バルーン方式というのがある
  • ヘッダーと被らないようCKEditorのツールバーを調整する
  • ヘッダーの位置を変える or そもそもヘッダー・フッター方式にしない

バルーン方式のCKEditorを触ったことがないので触ってみよう。

バルーン方式のCKEditorにしてみる

バルーン方式でCKEditorをBuildしなおした。

バルーン方式にした時の困難ポイントは以下のような感じ

  • 「import { BalloonEditor as BalloonEditorBase } from ‘@ckeditor/ckeditor5-editor-balloon’;」だけでなく、「import { BlockToolbar } from ‘@ckeditor/ckeditor5-ui’;」もインポート必要
  • 以下のようにツールバーとブロックツールバーで分かれている
blockToolbar: [
			'undo', 'redo',
			'|', 'heading',
			'|', 'ImageInsert', 'audioButton', 'mediaEmbed', 'blockQuote', 'insertTable',
			'|', 'bulletedList', 'numberedList', 'removeFormat', 'findAndReplace',
		],
		toolbar: {
			items: [
				'bold',
				'strikethrough',
				'link',
			]
		},
  • BallonEditorで制作しているので、エディタでは以下のようになる
    BalloonEditor.create(document.querySelector(‘#editor’), {
  • CKEditorと置き換えるタグはtextareaではだめ。divならいけた。textareaはClassicEditorじゃないとダメみたい。

使い勝手は以下のよう

慣れれば問題は無さそうなんだけど、編集するたびにボタンを押したり範囲選択したりと1動作増えるなぁという。

それと、私がGoogle Documentのような上に固定されているツールバーに慣れているのと、モバイル対応が難しそうという理由で没。

ちなみに、Noteはこのバルーン方式をとっていて、モバイル時だけ固定ツールバーにしてるみたい。

CKEditorのツールバー位置を調整する

公式ドキュメントを見ていたら、ツールバーの絶対位置を調整する方法があったのでこれを試す。

以下のようにすると

ClassicEditor
   .create(document.querySelector('#editor'), {
       ui: {
           // ヘッダー分上に間隔をとっている
           viewportOffset: {top: 47.84}
       },

以下のようにツールバーの絶対位置を調整できる。

うん、簡易的だけどこれでいいんじゃないかな。

CKEditorがそもそもモバイルに対応してくれているらしく、いい感じにレスポンシブにもなった。

アップロードしたファイル一覧画面もレスポンシブにする

他のページもレスポンシブ対応していったんだけど、このアップロードしたファイル一覧画面が結構曲者。

今の実装では、768pxくらいまでなら正常に見れるんだけど、それ以下になるとまともに閲覧できなくなる。

また、この画面レイアウトはQiitaを参考にしているんだけど、Qiitaは画像ファイルのみなのに対して、私の方は音源ファイルもあるから扱いが若干違うんだよね。

つまり、audioとimage要素を同時に閲覧できて、レスポンシブ対応もできるファイル管理画面を作成したい。

また、本当はソート機能とかも作りたい。

audioをJavaScriptで操作する

そもそも、audioとimage要素では必要な横幅が違ってくる。

今の仕様だと、音や写真のプレビューを104px*104pxの大きさで表示するようにしてるんだけど、以下のようにaudio要素は横に長くないと正常な機能として使えない。

もし、同じレイアウトにするならaudioも正方形のプレビュー画面にいい感じに表示されてほしい。

普通に再生と停止ボタンだけ表示させて音源を再生できるようにすればいいのでは? と思ったのでJavaScript使って頑張ってみる。

考えは

  • 動的にそれぞれの音源と結びついた再生ボタンを生成できる
  • 再生と停止だけできる
  • 同時に2つの音は再生できない。勝手に前の音が止まる
  • 音量調整どうしよう

一回音量調整は気にせずに上3つの要件を満たすものを作ってみる。

どうやらAlpine.storeというのを使えばalpineでグローバルスコープの状態管理ができるみたい。
また、JavaScriptにAudio要素を操作する機能があるみたい。メソッド名がわかりやすくてとてもよい。

ChatGPTやら公式ドキュメントとにらめっこしてできた最終的なコードがこちら

<div class="flex flex-shrink-0 content-center">
   {{--ボタンの状態は各インスタンスでisPlaying変数を作り管理する--}}
   <div x-data="{ audio: new Audio('{{ asset('storage/'.$file->file_path) }}'), isPlaying: false }"
        x-init="audio.addEventListener('play', () => isPlaying = true);
                   audio.addEventListener('pause', () => isPlaying = false);
                   audio.addEventListener('ended', () => isPlaying = false);">
       <button x-on:click="$store.audioPlayer.togglePlay(audio)">
           <span x-show="!isPlaying">再生</span>
           <span x-show="isPlaying">停止</span>
       </button>
   </div>
</div>

<script>
   // 音源の再生をalpine.storeで管理
   document.addEventListener('alpine:init', () => {
       Alpine.store('audioPlayer', {
           // 現在再生されているaudioインスタンスはここに入る
           audio: null,

           play(newAudio) {
               // 現在再生されていて、newAudioと違うなら現在の再生を止めて、時間を0に戻す
               if (this.audio && this.audio !== newAudio) {
                   this.audio.pause();
                   this.audio.currentTime = 0;
               }
               // 現在のaudioを新しいオーディオにし再生
               this.audio = newAudio;
               this.audio.play();
           },
           togglePlay(newAudio) {
               // 現在再生しているオーディオと一緒で、再生されているなら止める
               if (this.audio === newAudio && !this.audio.paused) {
                   this.audio.pause();
               } else {
                   this.play(newAudio);
               }
           },
       });
   });
</script>

かなりシンプルに出来たので大満足。

このコードのこだわりポイントは

  • 音ファイルの再生、停止のみできる
  • $store.audioPlayerで現在再生しているaudioインスタンスを管理しているので、同時に2音以上流れない
  • ボタンは$store.audioPlayerと分離して各インスタンスで管理しているので、直観的にボタンの状態管理ができる
  • 途中で止めても別の音を再生しなければ途中から再生できる
  • 最後まで音が流れたら「audio.addEventListener(‘ended’, () => isPlaying = false);」がイベント拾ってくれてボタン状態が戻る

という感じ

改めてJavaScriptの面白さとAlpine.jsの便利さを知った。
最後に再生ボタンと停止ボタンをアイコンにしてレスポンシブ対応すれば完了。

削除の確認はできればconfirmじゃなくdialogでやりたいんだけど、時間がかかりそうだったので一旦これで完了にする。

JavaScriptをどこに書くか

今の私は殆どのJavaScriptをbladeに直書きしている。

別にそれでも問題は無いんだけど、なんか別ファイルに分けるのが普通というイメージがあるので、そこら辺を調べる。

一般的な話

軽く調べてみて、一般的にどう考えられているかを知る。

JavaScriptを書ける場所は主に以下の2つがある。

  1. <script>タグ内に書く
  2. .jsファイルに書き、外部から取り込む

これ、それぞれどんなメリット、デメリットがあるかなんだけど、

  1. <script>タグ内に書く
    1. メリット
      1. HTMLと同じファイルなのでHTTP通信が1回で済む
      2. どこにJavaScriptが適用されているかわかりやすい
    2. デメリット
      1. HTMLがごちゃごちゃになる
      2. 繰り返しが増える場合もある。DRYの原則ってやつ
  2. 外部から取り込む
    1. メリット
      1. 別ファイルになっているので管理しやすい
      2. 使いまわせる
    2. デメリット
      1. HTTP通信が増える

ちなみに、MDN的には外部取り込みを

 コードを整理して、複数の HTML ファイルから再利用できるようにするには、このようにするのが良いでしょう。 大きなスクリプトの塊がないほうが、HTML も読みやすくなります。

https://developer.mozilla.org/ja/docs/Learn/JavaScript/First_steps/What_is_JavaScript

と言っていて、インラインイベントハンドラ(<button onclick=みたいなやつ)は

ただし、このようにはしないでください。 この書き方は HTML を JavaScript で汚してしまう悪い書き方です。さらに、onclick=”createParagraph()” という属性を JavaScript を適用したいボタンすべてに書かなければなりませんので、とても非効率です。

https://developer.mozilla.org/ja/docs/Learn/JavaScript/First_steps/What_is_JavaScript

と言っている。

でも、私はこのインラインイベントハンドラめっちゃ使ってるんだよね。
Alpine.jsが基本そういう書き方っぽいから。

私の考え

私の場合、学習コストとWebサイトの規模を考えてAlpine.jsというJSの軽量フレームワークを採用している。

Alpine.jsは「インラインでJavaScriptを気軽に書けるが売り」でもあるので、私の場合

  1. 軽いJS処理→Alpine.js
  2. 長いJS処理→Alpine.jsと<script>
  3. 使いまわせる長い処理→外部取り込み

で使い分けができている。

また、TailwindCSSのメリットと被るところもあると思ってる。

Tailwindのメリットは

  1. クラス名を考える必要がない
  2. HTMLとCSS(外部ファイル)を行き来する必要がなくなる
  3. 影響範囲の明確化
  4. デザインシステムの明確化

だったが、4番以外はAlpine.jsでも言えるメリットなのでは。

Alpine.jsはx-dataを宣言したタグの子要素にしか影響がないので影響範囲の明確化と言えるし、外部ファイルにしないので名前も考えなくてよいし、HTMLとJSファイルを行き来する必要もない。

正直、Tailwindの大きなメリットは4番だと思っているので肝心なメリットは無いんだけど、それでも一応メリットはある。

なので、とりあえず今の段階では

  1. 軽いJS処理→Alpine.js
  2. 長いJS処理→Alpine.jsと<script>
  3. 使いまわせる長い処理→外部取り込み

でも問題はないのかなと思う。

終わりに

なんかキリが良い終わり時が見つからなかったのでめっちゃ長くなってしまった。

前回、2月中にはリリースしたいとか言ってたけどどう考えても無理だった。

今回で見た目の部分は殆ど終わったので、あとは本当に最終確認して利用規約、サービス名、メールの文章とか作ってドメイン取ってクラウド契約してHTTPS化してOWASPやってデプロイすれば完成のはず。

まだやることはあるけど、完成が見えてきた。

おやすみなさい。