boring_avatars 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ # Rails binding 設計
2
+
3
+ ## 目的
4
+
5
+ Rails bindingは、Rails ViewからCoreのSVG生成を呼び出し、escape済みの結果を `ActiveSupport::SafeBuffer` として返す薄いadapterである。描画アルゴリズム、default値、validationをRails側に複製しない。
6
+
7
+ Coreの契約は [Core ライブラリ設計](core-library.md)、全体構成は [アーキテクチャ](architecture.md)、ロード境界を含むテストは [テスト・互換性設計](testing-and-compatibility.md) を参照する。
8
+
9
+ ## opt-inロード
10
+
11
+ 次の2つのrequire pathは明確に異なる契約を持つ。
12
+
13
+ ```ruby
14
+ require "boring_avatars"
15
+ ```
16
+
17
+ - Coreだけをロードする。
18
+ - ActiveSupport、ActionView、Rails bindingをロードしない。
19
+ - Railsが既にロード済みでもhelperを登録しない。
20
+
21
+ ```ruby
22
+ require "boring_avatars/bindings/rails"
23
+ ```
24
+
25
+ - Coreをロードする。
26
+ - `active_support/lazy_load_hooks` と `active_support/core_ext/string/output_safety` をロードする。
27
+ - View Helperを定義し、ActionViewのlazy-load hookへ登録する。
28
+ - ActionView自体をeager loadしない。
29
+
30
+ Rails利用者はGemfileで自動require先を指定できる。
31
+
32
+ ```ruby
33
+ gem "boring_avatars", require: "boring_avatars/bindings/rails"
34
+ ```
35
+
36
+ または、GemfileではCoreだけを読み込み、initializerなどでbindingを明示的にrequireする。
37
+
38
+ ## Helper登録
39
+
40
+ RailtieやEngineは作らず、entrypointで直接load hookを登録する。
41
+
42
+ ```ruby
43
+ require "boring_avatars"
44
+ require "active_support/lazy_load_hooks"
45
+ require "active_support/core_ext/string/output_safety"
46
+ require "boring_avatars/bindings/rails/view_helper"
47
+
48
+ ActiveSupport.on_load(:action_view) do
49
+ include ::BoringAvatars::Bindings::Rails::ViewHelper
50
+ end
51
+ ```
52
+
53
+ `ActiveSupport.on_load` はActionViewが既にload hookを実行済みなら登録時に即時適用し、未ロードなら後の `run_load_hooks(:action_view, self)` で適用する。このため、次の両方を同じ契約として保証する。
54
+
55
+ 1. bindingをrequireした後にActionViewをロードする。
56
+ 2. ActionViewをロードした後にbindingをrequireする。
57
+
58
+ gemのコードはRailsのapplication autoload pathやreload対象に置かない。`to_prepare`、initializer、Zeitwerk向け登録は不要とする。Rubyの `require` と同じく複数回のrequireは冪等でなければならない。
59
+
60
+ ## 依存関係
61
+
62
+ `boring_avatars.gemspec` に `rails`、`railties`、`actionview`、`activesupport` のruntime dependencyを追加しない。RubyGemsにはoptional runtime dependencyの表現がないため、追加するとCoreだけの利用者にもRails系gemのinstallを強制してしまう。
63
+
64
+ Rails bindingを利用するapplicationがRails/ActiveSupportを提供する。提供されていない環境でbindingをrequireした場合、`LoadError` をrescueせず伝播させる。警告だけでhelperを無効化する処理や、Coreへfallbackする処理は実装しない。
65
+
66
+ 開発・CIではRails系列ごとのGemfileからActionViewをdevelopment/test dependencyとして与える。
67
+
68
+ ## View Helper API
69
+
70
+ 公開helperは `boring_avatar` だけとする。
71
+
72
+ ```ruby
73
+ boring_avatar(
74
+ name,
75
+ variant: :marble,
76
+ colors: ["#92A1C6", "#146A7C", "#F0AB3D", "#C271B4", "#C20D90"],
77
+ size: "40px",
78
+ square: false,
79
+ title: false,
80
+ id_prefix: nil,
81
+ **svg_attributes
82
+ ) # => ActiveSupport::SafeBuffer
83
+ ```
84
+
85
+ - semantic optionの意味とvalidationはCoreと同じである。
86
+ - Rails独自defaultは持たない。
87
+ - Coreの `attributes:` 引数はhelperでは公開せず、残りのkeywordをroot SVG属性として扱う。
88
+ - 未知keywordは属性allowlistで検証され、許可されていなければ `ArgumentError` になる。
89
+ - Coreが送出した例外はそのまま呼び出し元へ伝える。
90
+
91
+ ### 使用例
92
+
93
+ ```erb
94
+ <%= boring_avatar(
95
+ current_user.email,
96
+ variant: :bauhaus,
97
+ size: "3rem",
98
+ class: ["avatar", "avatar--navigation"],
99
+ aria: { label: "#{current_user.name}のavatar" },
100
+ data: { controller: "avatar", user_id: current_user.id }
101
+ ) %>
102
+ ```
103
+
104
+ `name`、palette、属性値にuser inputが含まれていても、値はCore serializerでescapeされる。呼び出し側で `raw` や `html_safe` を付ける必要はない。
105
+
106
+ ## Rails形式属性の正規化
107
+
108
+ helperはCoreのflat属性allowlistに合わせ、次のRails向けshorthandだけを正規化する。
109
+
110
+ - `class:` はString、またはStringだけを含むArrayを許可する。Arrayは空要素を除外せず、空文字があれば `ArgumentError` とし、半角spaceで結合する。
111
+ - `data:` はHashを受け、keyの `_` を `-` に変換して `data-<key>` へ展開する。
112
+ - `aria:` はHashを受け、keyの `_` を `-` に変換して `aria-<key>` へ展開する。
113
+ - `id`、`lang`、`tabindex`、`focusable` はCoreと同じscalar値を受ける。
114
+ - `nil` は属性を省略し、booleanは文字列 `true` / `false` として渡す。
115
+
116
+ `data:` / `aria:` のnested Hash、ArrayやHashの自動JSON化、`class` 以外のArray展開は初版では扱わない。正規化後に同じ属性名が複数回現れた場合は、後勝ちにせず `ArgumentError` とする。
117
+
118
+ 属性値が `ActiveSupport::SafeBuffer` であってもsafeという印を信頼せず、Coreでは通常のStringとして再度XML escapeする。
119
+
120
+ ## 内部IDの一意性
121
+
122
+ Reactの `useId` に相当する一意性をRails View内で得るため、helperは `id_prefix: nil` の各呼び出しで次のprefixを生成する。
123
+
124
+ ```ruby
125
+ "ba-#{SecureRandom.hex(10)}"
126
+ ```
127
+
128
+ 生成したprefixをCoreの `id_prefix:` へ渡し、完成後のSVGを文字列置換しない。20桁hexの衝突確率は実用上無視でき、partial、fragment cache、Turboで別render結果が同じdocumentへ追加される場合にもview-local counterより安全である。
129
+
130
+ 呼び出し側が有効な `id_prefix` を明示した場合はそれをそのままCoreへ渡す。snapshotや固定HTMLを必要とする場合は、テストやapplication側で明示prefixを使える。
131
+
132
+ ## HTML safety
133
+
134
+ `html_safe` はescape処理ではなく、「これ以上escapeしなくてよい」という信頼宣言である。次の順序を変更しない。
135
+
136
+ 1. helperがRails形式属性を通常のRuby値へ正規化する。
137
+ 2. Coreが全入力を検証し、element treeを作る。
138
+ 3. Core serializerがtitle textとすべての属性値をXML escapeする。
139
+ 4. Coreが完成済みの通常のStringを返す。
140
+ 5. helperが最後に一度だけSafeBuffer化する。
141
+
142
+ SafeBuffer化後にname、属性、ID、wrapper markupを連結してはならない。生成済み `<svg>` 開始tagを正規表現で差し替える方法や、Railsの `tag.attributes` が返すmarkupをraw挿入する方法も採用しない。
143
+
144
+ ## Loadと安全性の受け入れ条件
145
+
146
+ - Core require後の `$LOADED_FEATURES` にActiveSupport、ActionView、Rails bindingがない。
147
+ - Railsが先にロードされてもCore requireだけでは `boring_avatar` が追加されない。
148
+ - bindingとActionViewのどちらを先にロードしても最終的にhelperが利用できる。
149
+ - helperの戻り値が `ActiveSupport::SafeBuffer` かつ `html_safe?` である。
150
+ - Core出力とhelper出力は、helperが付与する内部ID以外のDOM構造が一致する。
151
+ - 同じViewで2回呼んだ結果の内部ID集合が交差せず、すべてのfragment参照が各SVG内で解決する。
152
+ - Railsが存在しない場合にbinding requireが黙って成功しない。
@@ -0,0 +1,201 @@
1
+ # テスト・互換性設計
2
+
3
+ ## 互換性の基準
4
+
5
+ 移植基準は `boringdesigners/boring-avatars` のfull SHA [`d0ff2582a8921b643a89de4a4912be28938a828b`](https://github.com/boringdesigners/boring-avatars/tree/d0ff2582a8921b643a89de4a4912be28938a828b) とする。このcommitの `package.json` は2.0.4だが、GitHub latest releaseはv2.0.2である。fixture、設計書、third-party noticeにはshort SHAや可変の `master` URLではなくfull SHAを記録する。
6
+
7
+ テスト対象のCore APIは [Core ライブラリ設計](core-library.md)、Rails固有のload/safety契約は [Rails binding 設計](rails-binding.md)、実装順は [アーキテクチャ](architecture.md) を参照する。
8
+
9
+ ## 「描画互換」の定義
10
+
11
+ 有効な入力について、次を移植元と一致させる。
12
+
13
+ - UTF-16 code unitとsigned 32-bit overflowによるname hash
14
+ - paletteから色を選ぶindexと順序
15
+ - variantごとの固定path、要素数、要素順
16
+ - 座標、サイズ、角丸、回転、移動、scale
17
+ - Beamの顔色contrast、目、口、wrapper形状
18
+ - Marbleのblur filterとoverlay blend
19
+ - Pixelのrect配置と描画順
20
+ - Sunsetのgradient方向
21
+ - Ringのlayer構成
22
+ - 共通maskによるround/square形状
23
+
24
+ 比較対象外は次のとおり。
25
+
26
+ - React component APIとReact propsの型
27
+ - React `useId` が生成する文字列
28
+ - attribute順、self-closing表記などReactとRuby serializerの字句差
29
+ - whitespaceだけの差
30
+ - byte-for-byteのSVG文字列
31
+
32
+ 互換性テストは視覚的な印象だけで判断せず、正規化したSVG DOMの構造と属性値で判定する。
33
+
34
+ ## 意図的な非互換
35
+
36
+ | 移植元 | Ruby版 |
37
+ |---|---|
38
+ | name省略時は `Clara Barton` | nameは必須。空文字は許可 |
39
+ | 未知variantは `marble` | `ArgumentError` |
40
+ | `geometric` / `abstract` alias | 非対応、`ArgumentError` |
41
+ | 空paletteや無効色の挙動は未定義 | 1色以上の `#RRGGBB` を必須化 |
42
+ | 任意のReact SVG propsで固定属性も上書き可能 | 安全なroot属性allowlistだけ許可 |
43
+ | mask/filter IDはReact tree依存 | Coreは入力digest、Railsはrandom prefix |
44
+ | Sunset gradient IDは空白除去name | 安全なprefix由来ID |
45
+ | Reactがmarkupをescape | CoreのXML serializerが一元的にescape |
46
+
47
+ これらを後方互換目的でalias、fallback、警告付き受理へ変更しない。移植元の更新を取り込む場合も、Ruby版の公開契約変更として個別に判断する。
48
+
49
+ ## テスト基盤
50
+
51
+ - test frameworkはMinitestとする。
52
+ - `rake test` をローカルとCIの共通entrypointにする。
53
+ - `rake typecheck` で配布対象のRBSとSorbet RBIを検証する。RBSは `rbs -I sig validate` とSteepの公開API契約fixture、SorbetはRBIと `# typed: strong` の公開API契約fixtureを入力にする。実装本体へのSorbet導入とは分離する。
54
+ - 公開API manifestは実装から取得したpublic method集合と完全一致させ、Sorbet/RBS双方の契約fixtureが全項目を使用することを検査する。Sorbet fixtureの `untyped_usages` はゼロを必須とし、公開API型カバレッジ100%をCIの失敗条件にする。
55
+ - SVG parsingとcanonical比較にはdevelopment/test dependencyとしてNokogiriを使用し、runtime dependencyには追加しない。
56
+ - processごとのload状態を検証するテストは `Open3.capture3` などで新しいRuby processを起動する。
57
+ - Rails系列は `BUNDLE_GEMFILE` で切り替える明示的なmatrix Gemfileを使用し、Appraisalによる生成物を必須にしない。
58
+
59
+ テストは内部private methodへ過度に依存せず、公開出力、pinned fixture、load stateを中心にする。ただしJavaScript互換hashはアルゴリズムの破壊を早く特定できるよう、独立したunit testも持つ。
60
+
61
+ ## Upstream fixture
62
+
63
+ 移植元にはunit testや公式snapshotがないため、pinned commitからReact server-side renderingでfixtureを生成する。
64
+
65
+ ### 生成手順
66
+
67
+ 1. pinned SHAを一時directoryへcheckoutする。
68
+ 2. lockfileどおり `npm ci` とbuildを行う。
69
+ 3. `react-dom/server` の `renderToStaticMarkup` で公開 `Avatar` componentを描画する。
70
+ 4. 入力optionとraw SVGをfixtureへ保存する。
71
+ 5. fixture metadataへupstream repository、full SHA、Node/npm version、生成commandを記録する。
72
+
73
+ fixture更新scriptはnetworkやupstreamのdefault branchを暗黙参照せず、記録されたSHAを必須引数にする。通常のRuby test実行時はNodeやnetworkを必要とせず、commit済みfixtureだけを読む。
74
+
75
+ 実装済みの更新commandは `script/update_upstream_fixtures PATH_TO_PINNED_CHECKOUT` とする。指定checkoutのHEADが上記full SHAと一致しない場合は、fixtureを書き換える前に失敗する。
76
+
77
+ ### DOM正規化
78
+
79
+ ReactとRubyで異なる内部IDを比較可能にするため、双方をXMLとしてparseし、次の正規化を行う。
80
+
81
+ 1. document順に各 `id` を `internal-1`、`internal-2` のようなtokenへmapする。
82
+ 2. `mask`、`filter`、`fill` などの `url(#id)` fragment参照も同じmapで置換する。
83
+ 3. attribute順とempty-element表記をXML canonicalizationで統一する。
84
+ 4. style宣言はproperty/valueを保ったまま、serializer由来の空白だけを正規化する。
85
+ 5. ID以外の値、要素順、path data、transform、色は変更しない。
86
+
87
+ 参照先のないfragment、重複して異なる定義を持つID、SVG外を参照するURLがあれば、正規化前にテストを失敗させる。
88
+
89
+ ## Fixtureケース
90
+
91
+ 最低限、次を固定fixtureまたはtable-driven testとして持つ。
92
+
93
+ ### 描画
94
+
95
+ - 全6 variant × default palette × `Maria Mitchell`
96
+ - 全6 variantの `square: true`
97
+ - 全6 variantの `title: true`
98
+ - palette長1、2、5
99
+ - 数値sizeとstring size
100
+ - ASCIIの似た名前と長い名前
101
+
102
+ ### Unicode/hash
103
+
104
+ - 空文字
105
+ - `Clara Barton`
106
+ - `日本語`
107
+ - `😀`
108
+ - 合成文字 `é`
109
+ - 分解文字 `e\u0301`
110
+
111
+ 期待hashはCore設計に記載したvectorと一致させる。emojiはUTF-16 surrogate pairとして処理されることを個別に確認する。
112
+
113
+ ### Validationとsecurity
114
+
115
+ - `nil` やString以外のname
116
+ - invalid encodingとXML 1.0で禁止されるcontrol character
117
+ - 空palette、String以外のpalette、無効hex、空要素
118
+ - 未知variant、case違い、`geometric`、`abstract`
119
+ - `nil`、0、負数、NaN、Infinity、不正grammarのsize
120
+ - boolean以外のsquare/title
121
+ - 不正または長すぎるID prefix
122
+ - allowlist外属性、正規化後の重複属性、非scalar値
123
+ - tagやquoteを含むname、class、data、aria値
124
+ - `style`、`href`、`xlink:href`、`onload`、固定構造属性の拒否
125
+
126
+ 悪意ある値のテストは単に文字列に `<script>` がないことを見るだけでなく、XML parse後に想定外の要素・属性が増えていないことを確認する。
127
+
128
+ ## 内部IDテスト
129
+
130
+ Coreについて次を確認する。
131
+
132
+ - 同じ全入力から同じprefixとSVGが生成される。
133
+ - name、variant、palette、squareのいずれかが変わればprefixが変わる。
134
+ - size、title、追加root属性だけを変えてもdefs用prefixは変わらない。
135
+ - 全 `url(#...)` が同じSVG内の一意なIDへ解決する。
136
+ - Sunsetで同じname・異なるpaletteを並べてもgradientが混線しない。
137
+
138
+ Rails helperについて次を確認する。
139
+
140
+ - `SecureRandom` をstubしたテストでは指定prefixの安定したSVGを得られる。
141
+ - 通常の2回の呼び出しでは内部ID集合が互いに素になる。
142
+ - 明示的な `id_prefix` はCoreと同じvalidationを受ける。
143
+
144
+ ## Require境界テスト
145
+
146
+ 各シナリオを新しいRuby processで実行する。
147
+
148
+ 1. `require "boring_avatars"` のみでActiveSupport/ActionViewがロードされず、helperが存在しない。
149
+ 2. ActionViewを先にロードしてからCoreだけをrequireしてもhelperが追加されない。
150
+ 3. bindingをrequireしてからActionViewをロードするとhelperが追加される。
151
+ 4. ActionViewをロードしてからbindingをrequireしてもhelperが追加される。
152
+ 5. bindingを複数回requireしてもmodule ancestorや動作が重複しない。
153
+ 6. Rails系gemを利用できない環境でbinding requireが `LoadError` になる。
154
+ 7. helperの戻り値が `ActiveSupport::SafeBuffer` かつ `html_safe? == true` になる。
155
+ 8. Core validation errorがhelperから変更されず伝播する。
156
+
157
+ `$LOADED_FEATURES`、ActionViewのancestors、helper呼び出し結果を組み合わせ、定数が偶然定義されているだけの状態を成功とみなさない。
158
+
159
+ ## 対応matrix
160
+
161
+ 初版の `required_ruby_version` は `>= 3.3` とする。Core CIは次のRuby系列の最新patchを対象にする。
162
+
163
+ - Ruby 3.3
164
+ - Ruby 3.4
165
+ - Ruby 4.0
166
+
167
+ Rails bindingはActionView 8.0と8.1の最新patchをmatrix Gemfileで固定し、上記Ruby系列との全組み合わせをCI対象にする。dependency resolution不能な組み合わせを黙ってallow-failureにせず、対応範囲または依存選択の誤りとして解決する。
168
+
169
+ Rails 7以前と未検証の将来Rails majorはサポート対象に含めない。RailsがoptionalであるためgemspecにはRails version constraintを置かず、文書とCI結果を保証範囲の根拠とする。
170
+
171
+ ## Gem package検証
172
+
173
+ built gemに対して次を検証する。
174
+
175
+ - CoreとRails bindingの必要ファイルが含まれる。
176
+ - test、fixture、一時生成物、upstream checkoutを不要に同梱しない。
177
+ - Rails、ActionView、ActiveSupportのruntime dependencyがない。
178
+ - `required_ruby_version >= 3.3` が設定されている。
179
+ - gem metadataのlicenseがMITである。
180
+ - projectのLICENSEとupstream noticeが含まれる。
181
+
182
+ ## ライセンス
183
+
184
+ 移植元はMIT Licenseで、`Copyright (c) 2021 boringdesigners` の表示を持つ。Ruby版もMITとし、将来の実装・配布時には次を満たす。
185
+
186
+ - project自身の `LICENSE` を置く。
187
+ - upstreamのcopyrightとMIT license本文を `THIRD_PARTY_NOTICES.md` などへ実体として収録する。
188
+ - gemspecの `license` / `licenses` とpackage対象へ反映する。
189
+ - built gemにLICENSEとnoticeが含まれることを自動テストする。
190
+
191
+ 単にupstream URLだけを記載してlicense本文を省略してはならない。
192
+
193
+ ## 完成条件
194
+
195
+ - pinned fixtureとの正規化DOM比較が全6 variantで成功する。
196
+ - hash vector、validation、security、ID参照の全テストが成功する。
197
+ - Core-only requireとRails opt-in requireの境界が別processテストで保証される。
198
+ - Ruby 3.3・3.4・4.0およびRails 8.0・8.1 matrixが成功する。
199
+ - built gemの依存・同梱物・license検査が成功する。
200
+ - built gemに `sig/boring_avatars.rbs` と `rbi/boring_avatars.rbi` が同梱され、両方の型検証が成功する。
201
+ - 4つの設計文書でAPI、default、対応範囲、意図的非互換が一致している。
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module BoringAvatars
6
+ module Bindings
7
+ module Rails
8
+ module ViewHelper
9
+ UNSPECIFIED = Object.new.freeze
10
+
11
+ def boring_avatar(
12
+ name,
13
+ variant: :marble,
14
+ colors: UNSPECIFIED,
15
+ size: "40px",
16
+ square: false,
17
+ title: false,
18
+ id_prefix: nil,
19
+ **svg_attributes
20
+ )
21
+ prefix = id_prefix || "ba-#{SecureRandom.hex(10)}"
22
+ options = {
23
+ variant: variant,
24
+ size: size,
25
+ square: square,
26
+ title: title,
27
+ id_prefix: prefix,
28
+ attributes: normalize_boring_avatar_attributes(svg_attributes)
29
+ }
30
+ options[:colors] = colors unless colors.equal?(UNSPECIFIED)
31
+
32
+ svg = BoringAvatars.generate(name, **options)
33
+ svg.html_safe
34
+ end
35
+
36
+ private
37
+
38
+ def normalize_boring_avatar_attributes(attributes)
39
+ normalized = {}
40
+ attributes.each do |name, value|
41
+ next if value.nil?
42
+
43
+ case name.to_s
44
+ when "class"
45
+ store_boring_avatar_attribute(normalized, "class", normalize_boring_avatar_class(value))
46
+ when "data", "aria"
47
+ expand_boring_avatar_attributes(normalized, name.to_s, value)
48
+ else
49
+ normalized_name = normalize_boring_avatar_attribute_name(name)
50
+ store_boring_avatar_attribute(normalized, normalized_name, value)
51
+ end
52
+ end
53
+ normalized
54
+ end
55
+
56
+ def normalize_boring_avatar_class(value)
57
+ return value if value.is_a?(String)
58
+
59
+ unless value.is_a?(Array) && value.all? { |item| item.is_a?(String) && !item.empty? }
60
+ raise ArgumentError, "class must be a String or an Array of non-empty Strings"
61
+ end
62
+
63
+ value.join(" ")
64
+ end
65
+
66
+ def expand_boring_avatar_attributes(normalized, prefix, value)
67
+ raise ArgumentError, "#{prefix} must be a Hash" unless value.is_a?(Hash)
68
+
69
+ value.each do |nested_name, nested_value|
70
+ suffix = normalize_boring_avatar_nested_attribute_name(nested_name)
71
+ store_boring_avatar_attribute(normalized, "#{prefix}-#{suffix}", nested_value)
72
+ end
73
+ end
74
+
75
+ def normalize_boring_avatar_nested_attribute_name(value)
76
+ case value
77
+ when String, Symbol
78
+ value.to_s.tr("_", "-")
79
+ else
80
+ raise ArgumentError, "nested SVG attribute names must be String or Symbol"
81
+ end
82
+ end
83
+
84
+ def normalize_boring_avatar_attribute_name(value)
85
+ case value
86
+ when String
87
+ value
88
+ when Symbol
89
+ value.to_s.tr("_", "-")
90
+ else
91
+ raise ArgumentError, "SVG attribute names must be String or Symbol"
92
+ end
93
+ end
94
+
95
+ def store_boring_avatar_attribute(attributes, name, value)
96
+ raise ArgumentError, "duplicate SVG attribute: #{name}" if attributes.key?(name)
97
+
98
+ attributes[name] = value
99
+ end
100
+
101
+ private_constant :UNSPECIFIED
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "boring_avatars"
4
+ require "active_support/lazy_load_hooks"
5
+ require "active_support/core_ext/string/output_safety"
6
+ require "boring_avatars/bindings/rails/view_helper"
7
+
8
+ ActiveSupport.on_load(:action_view) do
9
+ include ::BoringAvatars::Bindings::Rails::ViewHelper
10
+ end
11
+
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module BoringAvatars
6
+ module Identifier
7
+ module_function
8
+
9
+ def call(input)
10
+ return input.id_prefix if input.id_prefix
11
+
12
+ parts = [
13
+ "v1",
14
+ input.variant.to_s,
15
+ input.square ? "1" : "0",
16
+ input.name,
17
+ input.colors.length.to_s,
18
+ *input.colors
19
+ ]
20
+ canonical = parts.map { |part| "#{part.bytesize}:#{part}" }.join
21
+ "ba-#{Digest::SHA256.hexdigest(canonical)[0, 20]}"
22
+ end
23
+ end
24
+ end
25
+
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "svg/serializer"
4
+
5
+ module BoringAvatars
6
+ DEFAULT_COLORS = ["#92A1C6", "#146A7C", "#F0AB3D", "#C271B4", "#C20D90"].map(&:freeze).freeze
7
+ VARIANTS = %w[marble beam pixel sunset ring bauhaus].freeze
8
+ COLOR_PATTERN = /\A#[0-9A-Fa-f]{6}\z/
9
+ SIZE_PATTERN = /\A(?<number>(?:\d+(?:\.\d+)?)|(?:\.\d+))(?<unit>px|em|rem|%|vw|vh|vmin|vmax)?\z/
10
+ ID_PREFIX_PATTERN = /\A[A-Za-z][A-Za-z0-9_-]{0,63}\z/
11
+ ATTRIBUTE_PATTERN = /\A(?:id|class|lang|tabindex|focusable|(?:aria|data)-[a-z0-9]+(?:-[a-z0-9]+)*)\z/
12
+
13
+ Input = Data.define(:name, :variant, :colors, :size, :square, :title, :id_prefix, :attributes) do
14
+ def initialize(
15
+ name:,
16
+ variant: :marble,
17
+ colors: DEFAULT_COLORS,
18
+ size: "40px",
19
+ square: false,
20
+ title: false,
21
+ id_prefix: nil,
22
+ attributes: {}
23
+ )
24
+ super(
25
+ name: normalize_string(name, "name"),
26
+ variant: normalize_variant(variant),
27
+ colors: normalize_colors(colors),
28
+ size: normalize_size(size),
29
+ square: normalize_boolean(square, "square"),
30
+ title: normalize_boolean(title, "title"),
31
+ id_prefix: normalize_id_prefix(id_prefix),
32
+ attributes: normalize_attributes(attributes)
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ def normalize_string(value, label)
39
+ raise ArgumentError, "#{label} must be a String" unless value.is_a?(String)
40
+
41
+ normalized = value.encode(Encoding::UTF_8)
42
+ unless Svg::Serializer.valid_xml_string?(normalized)
43
+ raise ArgumentError, "#{label} contains characters forbidden by XML 1.0"
44
+ end
45
+
46
+ normalized.freeze
47
+ rescue EncodingError => error
48
+ raise ArgumentError, "#{label} must be convertible to UTF-8: #{error.message}"
49
+ end
50
+
51
+ def normalize_variant(value)
52
+ variant = case value
53
+ when String then normalize_string(value, "variant")
54
+ when Symbol then normalize_string(value.to_s, "variant")
55
+ else raise ArgumentError, "variant must be a String or Symbol"
56
+ end
57
+ raise ArgumentError, "unknown variant: #{variant.inspect}" unless VARIANTS.include?(variant)
58
+
59
+ variant.to_sym
60
+ end
61
+
62
+ def normalize_colors(value)
63
+ raise ArgumentError, "colors must be a non-empty Array" unless value.is_a?(Array) && !value.empty?
64
+
65
+ value.map.with_index do |color, index|
66
+ normalized = normalize_string(color, "colors[#{index}]")
67
+ raise ArgumentError, "colors[#{index}] must use #RRGGBB format" unless COLOR_PATTERN.match?(normalized)
68
+
69
+ normalized
70
+ end.freeze
71
+ end
72
+
73
+ def normalize_size(value)
74
+ case value
75
+ when Integer
76
+ raise ArgumentError, "size must be greater than zero" unless value.positive?
77
+ value
78
+ when Float
79
+ raise ArgumentError, "size must be finite and greater than zero" unless value.finite? && value.positive?
80
+ value
81
+ when String
82
+ normalized = normalize_string(value, "size")
83
+ match = SIZE_PATTERN.match(normalized)
84
+ unless match && match[:number].to_f.positive?
85
+ raise ArgumentError, "size has an unsupported format"
86
+ end
87
+ normalized
88
+ else
89
+ raise ArgumentError, "size must be an Integer, Float, or String"
90
+ end
91
+ end
92
+
93
+ def normalize_boolean(value, label)
94
+ return value if value.equal?(true) || value.equal?(false)
95
+
96
+ raise ArgumentError, "#{label} must be true or false"
97
+ end
98
+
99
+ def normalize_id_prefix(value)
100
+ return if value.nil?
101
+
102
+ normalized = normalize_string(value, "id_prefix")
103
+ raise ArgumentError, "id_prefix has an unsupported format" unless ID_PREFIX_PATTERN.match?(normalized)
104
+
105
+ normalized
106
+ end
107
+
108
+ def normalize_attributes(value)
109
+ raise ArgumentError, "attributes must be a Hash" unless value.is_a?(Hash)
110
+
111
+ normalized = {}
112
+ seen = {}
113
+ value.each do |name, attribute_value|
114
+ normalized_name = normalize_attribute_name(name)
115
+ raise ArgumentError, "duplicate SVG attribute: #{normalized_name}" if seen.key?(normalized_name)
116
+
117
+ seen[normalized_name] = true
118
+
119
+ next if attribute_value.nil?
120
+
121
+ normalized[normalized_name.freeze] = normalize_attribute_value(attribute_value, normalized_name)
122
+ end
123
+ normalized.sort.to_h.freeze
124
+ end
125
+
126
+ def normalize_attribute_name(value)
127
+ name = case value
128
+ when String then normalize_string(value, "SVG attribute name")
129
+ when Symbol then normalize_string(value.to_s.tr("_", "-"), "SVG attribute name")
130
+ else raise ArgumentError, "SVG attribute names must be String or Symbol"
131
+ end
132
+ raise ArgumentError, "unsupported SVG attribute: #{name}" unless ATTRIBUTE_PATTERN.match?(name)
133
+
134
+ name
135
+ end
136
+
137
+ def normalize_attribute_value(value, name)
138
+ case value
139
+ when String
140
+ normalize_string(value, "attribute #{name}")
141
+ when Numeric
142
+ if value.respond_to?(:finite?) && !value.finite?
143
+ raise ArgumentError, "attribute #{name} must be finite"
144
+ end
145
+ value
146
+ when true, false
147
+ value
148
+ else
149
+ raise ArgumentError, "attribute #{name} has an unsupported value type"
150
+ end
151
+ end
152
+ end
153
+ private_constant :Input, :DEFAULT_COLORS, :VARIANTS, :COLOR_PATTERN, :SIZE_PATTERN,
154
+ :ID_PREFIX_PATTERN, :ATTRIBUTE_PATTERN
155
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringAvatars
4
+ module NameHash
5
+ UINT32_MASK = 0xFFFF_FFFF
6
+ INT32_SIGN = 0x8000_0000
7
+ UINT32_SIZE = 0x1_0000_0000
8
+
9
+ module_function
10
+
11
+ def call(name)
12
+ name.encode(Encoding::UTF_16LE).unpack("v*").reduce(0) do |hash, code_unit|
13
+ value = ((hash * 31) + code_unit) & UINT32_MASK
14
+ value >= INT32_SIGN ? value - UINT32_SIZE : value
15
+ end.abs
16
+ end
17
+ end
18
+ end
19
+