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.
- checksums.yaml +7 -0
- data/LICENSE +22 -0
- data/README.md +62 -0
- data/THIRD_PARTY_NOTICES.md +30 -0
- data/docs/architecture.md +161 -0
- data/docs/core-library.md +257 -0
- data/docs/rails-binding.md +152 -0
- data/docs/testing-and-compatibility.md +201 -0
- data/lib/boring_avatars/bindings/rails/view_helper.rb +105 -0
- data/lib/boring_avatars/bindings/rails.rb +11 -0
- data/lib/boring_avatars/identifier.rb +25 -0
- data/lib/boring_avatars/input.rb +155 -0
- data/lib/boring_avatars/name_hash.rb +19 -0
- data/lib/boring_avatars/renderer.rb +91 -0
- data/lib/boring_avatars/svg/element.rb +16 -0
- data/lib/boring_avatars/svg/serializer.rb +67 -0
- data/lib/boring_avatars/svg/value.rb +17 -0
- data/lib/boring_avatars/utilities.rb +34 -0
- data/lib/boring_avatars/variants/bauhaus.rb +76 -0
- data/lib/boring_avatars/variants/beam.rb +130 -0
- data/lib/boring_avatars/variants/marble.rb +85 -0
- data/lib/boring_avatars/variants/pixel.rb +49 -0
- data/lib/boring_avatars/variants/ring.rb +42 -0
- data/lib/boring_avatars/variants/sunset.rb +57 -0
- data/lib/boring_avatars/variants.rb +32 -0
- data/lib/boring_avatars/version.rb +5 -0
- data/lib/boring_avatars.rb +34 -0
- data/rbi/boring_avatars.rbi +70 -0
- data/sig/boring_avatars.rbs +44 -0
- metadata +155 -0
|
@@ -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
|
+
|