Repository: fp-matsuri/2025.fp-matsuri.org
Pull request #87: feat: スポンサー広告を追加する
Author: login: y047aka
Created: 2025-05-14T08:11:07Z | Merged: 2025-05-14T11:10:17Z

- スポンサーページに広告を追加します（対象：3社）
- 同一プラン内で順番がランダムになるよう、トップページと同様のシャッフル処理を追加します

広告のテキストはなるべくオリジナルを尊重していますが、

- レンダリングエラーになる `<p>` タグの除去
  - 各ブロックはデフォルトで `<p>` として扱われるので、削除しても問題ない
- レンダリング時に省略される改行を、 `<br />` の追加により再現
- 末尾のリンクが複数ある場合に `-` によるリストに変更

などの調整を入れています。



Diff:
diff --git a/app/Data/Sponsor.elm b/app/Data/Sponsor.elm
index 996b082..0b7466f 100644
--- a/app/Data/Sponsor.elm
+++ b/app/Data/Sponsor.elm
@@ -3,6 +3,7 @@ module Data.Sponsor exposing
     , SponsorMetadata, metadataDecoder
     , Plan(..), planToBadge
     , IframeData(..)
+    , shuffle
     )
 
 {-|
@@ -12,6 +13,8 @@ module Data.Sponsor exposing
 @docs Plan, planToBadge
 @docs IframeData
 
+@docs shuffle
+
 -}
 
 import Css exposing (block, display, px, width)
@@ -19,6 +22,8 @@ import Html.Styled as Html exposing (Html, img, text)
 import Html.Styled.Attributes exposing (css, src)
 import Json.Decode as Decode exposing (Decoder)
 import Markdown.Block exposing (Block)
+import Random
+import Random.List
 
 
 type alias SponsorArticle =
@@ -115,3 +120,15 @@ planToBadge plan =
     )
         |> Maybe.map (\badgeImage -> img [ src ("/images/sponsor-labels/" ++ badgeImage), css [ display block, width (px 80) ] ] [])
         |> Maybe.withDefault (text "")
+
+
+{-| 与えられたリストの要素をランダムな順序に並べ替えます
+-}
+shuffle : Int -> List a -> List a
+shuffle seed list =
+    let
+        generator =
+            Random.List.shuffle list
+    in
+    Random.step generator (Random.initialSeed seed)
+        |> (\( shuffledList, _ ) -> shuffledList)
diff --git a/app/Route/Index.elm b/app/Route/Index.elm
index 584af9d..f4dbc85 100644
--- a/app/Route/Index.elm
+++ b/app/Route/Index.elm
@@ -6,7 +6,7 @@ import Css exposing (..)
 import Css.Extra exposing (columnGap, fr, grid, gridColumn, gridRow, gridTemplateColumns, gridTemplateRows, rowGap)
 import Css.Global exposing (children, descendants, withClass)
 import Css.Media as Media exposing (only, screen, withMedia)
-import Data.Sponsor exposing (Plan(..))
+import Data.Sponsor as Sponsor exposing (Plan(..))
 import Effect exposing (Effect)
 import FatalError exposing (FatalError)
 import FpMatsuri.BackgroundTexture as BackgroundTexture
@@ -143,7 +143,7 @@ hero seed time sponsorsData =
                         , href = article.metadata.href
                         }
                     )
-                |> shuffleList seed
+                |> Sponsor.shuffle seed
     in
     div
         [ css
@@ -668,27 +668,6 @@ type alias Sponsor =
     }
 
 
-{-| 与えられたリストの要素をランダムな順序に並べ替えます
-
-    1. リストの各要素に0〜1のランダムな値を割り当てる
-    2. ランダム値でソートすることでリストをシャッフル
-    3. ランダム値を取り除いて元の要素だけを返す
-
--}
-shuffleList : Int -> List a -> List a
-shuffleList seed list =
-    let
-        generator =
-            Random.list (List.length list) (Random.float 0 1)
-    in
-    Random.initialSeed seed
-        |> Random.step generator
-        |> Tuple.first
-        |> List.map2 Tuple.pair list
-        |> List.sortBy Tuple.second
-        |> List.map Tuple.first
-
-
 sponsorLogos : Int -> Sponsors.Data -> Html msg
 sponsorLogos randomSeed sponsorsData =
     let
@@ -702,7 +681,7 @@ sponsorLogos randomSeed sponsorsData =
                         , href = article.metadata.href
                         }
                     )
-                |> shuffleList randomSeed
+                |> Sponsor.shuffle randomSeed
     in
     div
         [ css
@@ -730,7 +709,7 @@ sponsorLogos randomSeed sponsorsData =
             -- TODO: メタデータにimageプロパティを追加する
             (sponsorsData.personalSupporters
                 |> List.map (\{ metadata } -> { name = metadata.name, image = metadata.id, href = metadata.href })
-                |> shuffleList randomSeed
+                |> Sponsor.shuffle randomSeed
             )
         , sponsorPlan "協力"
             { mobileColumnsCount = 4, desktopColumnWidth = "116px" }
diff --git a/app/Route/Sponsors.elm b/app/Route/Sponsors.elm
index c4ee8f4..25e420a 100644
--- a/app/Route/Sponsors.elm
+++ b/app/Route/Sponsors.elm
@@ -5,7 +5,8 @@ import BackendTask.Glob as Glob
 import Css exposing (..)
 import Css.Extra exposing (columnGap, grid, paddingBlock)
 import Css.Media as Media exposing (only, screen, withMedia)
-import Data.Sponsor exposing (IframeData(..), Plan(..), SponsorArticle, metadataDecoder, planToBadge)
+import Data.Sponsor as Sponsor exposing (IframeData(..), Plan(..), SponsorArticle, metadataDecoder, planToBadge)
+import Effect exposing (Effect)
 import FatalError exposing (FatalError)
 import Head
 import Head.Seo
@@ -18,7 +19,8 @@ import Markdown.Html
 import Markdown.Renderer exposing (Renderer)
 import PagesMsg exposing (PagesMsg)
 import Plugin.MarkdownCodec
-import RouteBuilder exposing (App, StatelessRoute)
+import Random
+import RouteBuilder exposing (App, StatefulRoute)
 import Shared
 import Site
 import Svg.Styled.Attributes exposing (style)
@@ -26,11 +28,11 @@ import View exposing (View)
 
 
 type alias Model =
-    {}
+    { seed : Int }
 
 
-type alias Msg =
-    ()
+type Msg
+    = GotRandomSeed Int
 
 
 type alias RouteParams =
@@ -51,10 +53,29 @@ type alias ActionData =
     {}
 
 
-route : StatelessRoute RouteParams Data ActionData
+route : StatefulRoute RouteParams Data ActionData Model Msg
 route =
     RouteBuilder.single { head = head, data = data }
-        |> RouteBuilder.buildNoState { view = view }
+        |> RouteBuilder.buildWithLocalState
+            { init = init
+            , update = update
+            , view = view
+            , subscriptions = \_ _ _ _ -> Sub.none
+            }
+
+
+init : App Data ActionData RouteParams -> Shared.Model -> ( Model, Effect Msg )
+init _ _ =
+    ( { seed = 0 }
+    , Effect.fromCmd (Random.generate GotRandomSeed (Random.int 0 100))
+    )
+
+
+update : App Data ActionData RouteParams -> Shared.Model -> Msg -> Model -> ( Model, Effect Msg )
+update _ _ msg model =
+    case msg of
+        GotRandomSeed newSeed ->
+            ( { model | seed = newSeed }, Effect.none )
 
 
 data : BackendTask FatalError Data
@@ -127,15 +148,16 @@ sponsorFilesByPlan planName =
 view :
     App Data ActionData RouteParams
     -> Shared.Model
+    -> Model
     -> View (PagesMsg Msg)
-view d _ =
+view d _ model =
     { title = "スポンサー"
-    , body = [ Html.section [] [ sponsorsSection d.data ] ]
+    , body = [ Html.section [] [ sponsorsSection model.seed d.data ] ]
     }
 
 
-sponsorsSection : Data -> Html msg
-sponsorsSection pageData =
+sponsorsSection : Int -> Data -> Html msg
+sponsorsSection seed pageData =
     div
         []
         (List.map
@@ -176,36 +198,16 @@ sponsorsSection pageData =
                         ]
                     ]
             )
-            ((pageData.platinumSponsors ++ pageData.goldSponsors ++ pageData.silverSponsors)
-                |> List.filter (\s -> s.body /= [])
-                |> sortSponsors
+            ([ pageData.platinumSponsors
+             , pageData.goldSponsors
+             , pageData.silverSponsors
+             ]
+                |> List.map (List.filter (\s -> s.body /= []) >> Sponsor.shuffle seed)
+                |> List.concat
             )
         )
 
 
-sortSponsors : List SponsorArticle -> List SponsorArticle
-sortSponsors =
-    List.sortBy
-        (\s ->
-            let
-                priority =
-                    case s.metadata.plan of
-                        Platinum ->
-                            3
-
-                        Gold ->
-                            2
-
-                        Silver ->
-                            1
-
-                        _ ->
-                            0
-            in
-            ( -priority, s.metadata.postedAt )
-        )
-
-
 sponsorLogo : String -> String -> String -> Html msg
 sponsorLogo image name site =
     a
@@ -339,5 +341,6 @@ customizedHtmlRenderer =
                             children
                     )
                     |> Markdown.Html.withAttribute "href"
+                , Markdown.Html.tag "strong" (\children -> PlainHtml.strong [] children)
                 ]
     }
diff --git a/content/sponsors/platinum/3-shake.md b/content/sponsors/platinum/3-shake.md
index 824e44c..313bfad 100644
--- a/content/sponsors/platinum/3-shake.md
+++ b/content/sponsors/platinum/3-shake.md
@@ -5,3 +5,45 @@ href: https://3-shake.com/
 plan: プラチナ
 postedAt: 2025-04-03 09:47
 ---
+
+# 会社紹介
+株式会社スリーシェイクは、代表の吉田をはじめ、SREコンサルティングやデータ活用基盤など、ITインフラ領域の技術力に強みを持つメンバーを中心に構成されたテックカンパニーです。
+
+現在、中長期視点での事業拡大に向けて、組織拡大の下地を作っております。
+
+■ 社会の根幹となるようなサービスを出し続ける<br />
+私達スリーシェイクの社名の由来はネットワーク接続技術の基本である「3 way handshaking」。<br />
+インターネットの根幹を支えるこの技術のように、社会の根幹となるイノベーティブなプロダクトを連続的に生み出し、世界の進歩を加速させていきたい。<br />
+そんな想いで、事業やサービスの開発・改善に取り組んでいます。
+
+■世の中になくてはならない企業になることを目指して<br />
+私たちは毎年2ケタ以上の成長を遂げていますが、現状に満足してはいません。<br />
+私たちの生活に欠かせない、社会の根幹となる新たなプロダクトを生み出すべく、日々構想しています。<br />
+世の中になくてはならない企業となるために、コツコツと成果を積み上げながらも、全力で挑戦していきます。
+
+■Googleテックパートナーに認定！ / 参入障壁の高い、高度なインフラ技術が強み<br />
+スリーシェイクは技術的に難しいインフラ技術のテンプレート化を実現したことにより、お客様ごとにカスタマイズを必要としない、汎用性の高いプロダクトを生み出すことに成功しています。<br />
+また、客先で必要となるミドルウェアやデータベースも社内で1から10まで内製することで、導入までのリードタイムを短縮し、低コストで柔軟なクラウド環境構築を叶えています。<br />
+その結果、Reckonerが日本で3番目となるGoogle社のテックパートナーに認定され、Googleからの販売支援も受けています。
+
+
+# 事業内容
+◆クラウド型データ連携プラットフォーム事業「<a href="https://reckoner.io/">Reckoner</a>」<br />
+クラウド型データ加工・連携プラットフォームです。<br />
+デジタル化に伴うデータ活用、業務自動化ニーズに向けて、データ活用/業務最適化の課題を非エンジニアでも使いこなせるノーコード基盤で解決します。
+
+スリーシェイクは「企業経営のインフラ（エンジニアリング）を支えるプラットフォーム」として、その他下記の事業も展開しています。
+
+▼ SRE技術支援サービス事業「<a href="https://sreake.com/">Sreake</a>」<br />
+弊社の主力事業であり、SRE(Site Reliability Engineering)の導入・実践に向けた日本初の伴走型コンサルティングサービス。
+
+▼ 脆弱性診断SaaS事業「<a href="https://www.securify.jp/">Securify</a>」<br />
+複雑化するサイバー攻撃に対して事業者が抱えるセキュリティリスクを無くすために、簡単操作で高性能なセキュリティチェックが実現できるプラットフォーム。
+
+▼ フリーランスエンジニア人材紹介事業「<a href="https://relance.jp/">Relance</a>」<br />
+「いいエンジニア」をあなたのチームに、をテーマとしたITフリーランスエンジニアの人材紹介エージェント。
+
+
+スリーシェイクをより詳しく知りたい方はこちら
+- <a href="https://jobs-3-shake.com/">▽▼▽スリーシェイク採用情報ページ▽▼▽</a>
+- <a href="https://hrmos.co/pages/threeshake/jobs?">3-shakeエンジニア求人票一覧</a>
diff --git a/content/sponsors/silver/classmethod.md b/content/sponsors/silver/classmethod.md
index dbbd504..a7a80a3 100644
--- a/content/sponsors/silver/classmethod.md
+++ b/content/sponsors/silver/classmethod.md
@@ -5,3 +5,23 @@ href: https://classmethod.jp/
 plan: シルバー
 postedAt: 2025-05-08 13:09
 ---
+
+クラスメソッドは<strong>「オープンな発想と高い技術力により、すべての人々の創造活動に貢献し続ける。」</strong>という<strong>経営理念</strong>のもと、クラウド、モバイル、ビッグデータ、生成AIのコンサルティングやシステム開発、運用サービスを提供しています。
+
+また、すべての人々の創造活動に貢献し続けるため、自社メディア<a href="https://dev.classmethod.jp/" target="_blank" rel="noopener noreferrer">DevelopersIO</a> を通じて多くの技術情報を発信し続けています。
+2021年2月には技術情報サービス<a href="https://zenn.dev/" target="_blank" rel="noopener noreferrer">Zenn</a>がクラスメソッドの仲間になり、2つのメディアを通じて日本中のエンジニアの下支えを行っています。
+
+クラスメソッドといえばAWSのイメージが強いかもしれませんが、モバイルアプリ開発・データ分析などさまざまな技術分野で大手カフェチェーン企業様に向けた技術支援を専任チームを設けて継続的に行っており、<strong>Scala、Rustのエンジニアを募集中</strong>です。
+
+<strong>募集ページ</strong>
+<ul>
+  <li><a href="https://careers.classmethod.jp/requirements/sbj-serverside-scala/" target="_blank" rel="noopener noreferrer">Scalaエンジニア募集ページ</a></li>
+  <li><a href="https://careers.classmethod.jp/requirements/sbj-serverside-rust/" target="_blank" rel="noopener noreferrer">Rustエンジニア募集ページ</a></li>
+</ul>
+
+クラスメソッドでは、これまでもお客様へ<strong>積極的に提案・アドバイス</strong>を行い、さまざまなシステム/アプリを共に作り上げてきました。
+<strong>お客様と一体となってのチーム開発や技術選定から関われる環境</strong>で、是非一緒に働きませんか？
+
+カジュアル面談等も可能ですので、少しでも興味を持っていただいた方は是非以下からご連絡下さい。
+- <a href="https://classmethodcasual.hp.peraichi.com/" target="_blank" rel="noopener noreferrer">カジュアル面談ページ</a>
+- <a href="https://careers.classmethod.jp/" target="_blank" rel="noopener noreferrer">採用情報サイト</a>
diff --git a/content/sponsors/silver/stanby.md b/content/sponsors/silver/stanby.md
index 692c690..848ae1d 100644
--- a/content/sponsors/silver/stanby.md
+++ b/content/sponsors/silver/stanby.md
@@ -5,3 +5,18 @@ href: https://stanby.co.jp/
 plan: シルバー
 postedAt: 2025-04-23 18:48
 ---
+
+▼スタンバイについて
+
+現株式会社スタンバイは日本最大級の求人検索エンジン「スタンバイ」を運営しています。
+
+私たちは現在提供しているサービスの先に、日本における「労働人口の減少」、「生産性向上」など大きな社会の課題解決を見据えています。<br />
+こういった大きな社会の課題に取り組むため、私たちはZホールディングスグループが持つ様々なアセットや、Visionalグループが持つ求人・採用ノウハウ、技術力を掛け合わせた新たなチャレンジをはじめます。
+
+まずは、求職者にとって集約的・一元的に求人情報を探せること、求人募集企業様にとって簡単かつ効率的に人材を確保できること、その先には、よりライフスタイルに合わせた働き方の選択や、あらゆる人が自身のキャリアを向上させていくことなど、私たちが社会に提供する価値への挑戦はまだ始まったばかりです。
+
+私たちは“UPDATE WORKSTYLES「はたらく」にもっと彩りを“をミッション掲げ、テクノロジーの力を通じて、様々な働き方を尊重、支援し、人々の「はたらく」の在り方をアップデートします。
+
+- [採用サイト](https://recruit.stanby.co.jp/)
+- [Stanby Tech Blog /技術ブログ](https://techblog.stanby.co.jp/)
+- [プロダクト開発募集職種](https://hrmos.co/pages/stanby-inc/jobs?category=1445664202436182016)
diff --git a/elm.json b/elm.json
index 74a20ca..0541b11 100644
--- a/elm.json
+++ b/elm.json
@@ -25,6 +25,7 @@
             "elm/url": "1.0.0",
             "elm-community/dict-extra": "2.4.0",
             "elm-community/list-extra": "8.7.0",
+            "elm-community/random-extra": "3.2.0",
             "justinmimbs/time-extra": "1.2.0",
             "justinmimbs/timezone-data": "11.0.0",
             "rtfeldman/elm-css": "18.0.0",


Reviews:
user: login: y047aka
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T08:17:21Z
user: login: y047aka
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T08:20:19Z
user: login: y047aka
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T08:20:40Z
user: login: wm3
body: ありがとうございます！

コードも順当に整理されているようで良さそうに思います。

state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-14T08:59:44Z
user: login: wm3
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T09:01:59Z
user: login: y047aka
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T11:05:31Z

Review comments:
diff_hunk: @@ -115,3 +119,24 @@ planToBadge plan =
     )
         |> Maybe.map (\badgeImage -> img [ src ("/images/sponsor-labels/" ++ badgeImage), css [ display block, width (px 80) ] ] [])
         |> Maybe.withDefault (text "")
+
+
+{-| 与えられたリストの要素をランダムな順序に並べ替えます
+
+    1. リストの各要素に0〜1のランダムな値を割り当てる
+    2. ランダム値でソートすることでリストをシャッフル
+    3. ランダム値を取り除いて元の要素だけを返す
+
+-}
+shuffle : Int -> List a -> List a
path: app/Data/Sponsor.elm
user: login: y047aka
body: 改修前の `shuffleList` を切り出しました。

リストのシャッフルには [Random.Extra.shuffle](https://package.elm-lang.org/packages/elm-community/random-extra/latest/Random-List#shuffle) を使うことも可能ですが、 `shuffle : List a -> Generator (List a)` だと、この関数の一部分を補完するだけなので、今回は採用を見送っています。
（少しわかりやすくなると思うので、使ってみてもいいかも）
created_at: 2025-05-14T08:17:21Z
author_association: COLLABORATOR
diff_hunk: @@ -176,36 +198,16 @@ sponsorsSection pageData =
                         ]
                     ]
             )
-            ((pageData.platinumSponsors ++ pageData.goldSponsors ++ pageData.silverSponsors)
-                |> List.filter (\s -> s.body /= [])
-                |> sortSponsors
+            ([ pageData.platinumSponsors
+             , pageData.goldSponsors
+             , pageData.silverSponsors
+             ]
+                |> List.map (List.filter (\s -> s.body /= []) >> Sponsor.shuffle seed)
+                |> List.concat
             )
         )
 
 
-sortSponsors : List SponsorArticle -> List SponsorArticle
-sortSponsors =
path: app/Route/Sponsors.elm
user: login: y047aka
body: https://github.com/fp-matsuri/2025.fp-matsuri.org/pull/75/files#r2055498127 の時点で、このソート処理は不要になっていました。最初から `platinumSponsors` `goldSponsors` `silverSponsors` に分かれているので、これを順番に繋げるだけでOK
created_at: 2025-05-14T08:20:19Z
author_association: COLLABORATOR
diff_hunk: @@ -339,5 +341,6 @@ customizedHtmlRenderer =
                             children
                     )
                     |> Markdown.Html.withAttribute "href"
+                , Markdown.Html.tag "strong" (\children -> PlainHtml.strong [] children)
path: app/Route/Sponsors.elm
user: login: y047aka
body: strongタグのパースを追加
created_at: 2025-05-14T08:20:39Z
author_association: COLLABORATOR
diff_hunk: @@ -115,3 +119,24 @@ planToBadge plan =
     )
         |> Maybe.map (\badgeImage -> img [ src ("/images/sponsor-labels/" ++ badgeImage), css [ display block, width (px 80) ] ] [])
         |> Maybe.withDefault (text "")
+
+
+{-| 与えられたリストの要素をランダムな順序に並べ替えます
+
+    1. リストの各要素に0〜1のランダムな値を割り当てる
+    2. ランダム値でソートすることでリストをシャッフル
+    3. ランダム値を取り除いて元の要素だけを返す
+
+-}
+shuffle : Int -> List a -> List a
+shuffle seed list =
+    let
+        generator =
+            Random.list (List.length list) (Random.float 0 1)
+    in
+    Random.initialSeed seed
+        |> Random.step generator
+        |> Tuple.first
+        |> List.map2 Tuple.pair list
+        |> List.sortBy Tuple.second
+        |> List.map Tuple.first
path: app/Data/Sponsor.elm
user: login: wm3
body: あまり関数型言語に慣れていないせいか、ここらへんのパイプラインは短期記憶が要求される(この時点ではタプルのリストだとかここで元のリストに戻るとかあ)感じがしちゃいますね…。
created_at: 2025-05-14T09:01:59Z
author_association: COLLABORATOR
diff_hunk: @@ -115,3 +119,24 @@ planToBadge plan =
     )
         |> Maybe.map (\badgeImage -> img [ src ("/images/sponsor-labels/" ++ badgeImage), css [ display block, width (px 80) ] ] [])
         |> Maybe.withDefault (text "")
+
+
+{-| 与えられたリストの要素をランダムな順序に並べ替えます
+
+    1. リストの各要素に0〜1のランダムな値を割り当てる
+    2. ランダム値でソートすることでリストをシャッフル
+    3. ランダム値を取り除いて元の要素だけを返す
+
+-}
+shuffle : Int -> List a -> List a
+shuffle seed list =
+    let
+        generator =
+            Random.list (List.length list) (Random.float 0 1)
+    in
+    Random.initialSeed seed
+        |> Random.step generator
+        |> Tuple.first
+        |> List.map2 Tuple.pair list
+        |> List.sortBy Tuple.second
+        |> List.map Tuple.first
path: app/Data/Sponsor.elm
user: login: y047aka
body: 短期記憶が必要なロジックを `Random.List.shuffle` に肩代わりしてもらいました。Randomモジュールに関する知識は必要ですが、高階関数の内部でシャッフルが完了し、最後はタプルから結果部分を取り出すだけに変わりました。
（Tuplu.first も `(\( shuffledList, _ ) -> shuffledList)` に書き換えました）
https://github.com/fp-matsuri/2025.fp-matsuri.org/pull/87/commits/4d858f7fecd536fca4e982a229b323f68691dc8d
created_at: 2025-05-14T11:05:31Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-14T08:12:04Z
body: Preview deployment available at https://2025fp-matsuri-aw3e5cmna-keitans-projects.vercel.app
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-14T11:03:19Z
body: Preview deployment available at https://2025fp-matsuri-8q5q46ae7-keitans-projects.vercel.app
author_association: NONE
user: login: y047aka
created_at: 2025-05-14T11:08:04Z
body: マージします！
author_association: COLLABORATOR