citrine 0.2.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,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Citrine
4
+ # 批量调度(S1-1):同一同步窗口内的多次 Signal#set 合并为一轮 Effect 重跑——
5
+ # 去重后每个受影响的 Effect 只重跑一次,中间态不入 DOM。
6
+ # 窗口:事件处理器的分发过程自动包裹一次;应用代码也可用 Citrine.batch 显式开启。
7
+ class Scheduler
8
+ class << self
9
+ # 开启合并窗口:块内所有 set 只入队不广播,块返回后 flush 一次跑完。
10
+ # 可嵌套(内层不 flush);块抛异常时同样 flush(与同步语义一样保留已写入的状态)。
11
+ def batch
12
+ @depth = (@depth || 0) + 1
13
+ begin
14
+ yield
15
+ ensure
16
+ @depth -= 1
17
+ if @depth.zero?
18
+ pending = $! # 用户块抛出的异常(若有)正在传播
19
+ begin
20
+ flush
21
+ rescue StandardError
22
+ raise if pending.nil? # 无用户异常时调度错误照常抛出
23
+ # 用户原始异常优先:flush 的错误不替换它(warn 已由 flush 发出)
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def batching?
30
+ !@depth.nil? && @depth > 0
31
+ end
32
+
33
+ # 去重入队:同一 Effect 被多个信号命中也只跑一次。
34
+ # 以 effect 对象本身作 Hash 键(identity):object_id 会被 GC 复用,
35
+ # 复用期内同 id 的新 effect 会被误判为"已入队"而静默丢弃更新。
36
+ def schedule(effect)
37
+ return if @queued && @queued.key?(effect)
38
+
39
+ (@queue ||= []) << effect
40
+ (@queued ||= {})[effect] = true
41
+ end
42
+
43
+ # 排空队列。重跑期间产生的新写入(depth 已归零)走同步广播——
44
+ # 与窗口外的既有语义一致,也避免循环依赖时无限排空。
45
+ # 单个 effect 抛错不中断本轮其余项:逐项 rescue、记档第一个错误,
46
+ # 队列排空后统一 warn 并重新抛出它——既避免同批更新半途丢弃,
47
+ # 也不让调度错误从 ensure 路径穿出时替换用户块的原始异常
48
+ # (batch 的 ensure 依 $! 判定:用户异常在传播时吞掉调度错误,只保留 warn)。
49
+ def flush
50
+ first_error = nil
51
+ while @queue && !@queue.empty?
52
+ current = @queue
53
+ @queued = nil
54
+ @queue = nil
55
+ current.each do |effect|
56
+ begin
57
+ effect.run
58
+ rescue StandardError => e
59
+ first_error ||= e
60
+ warn "[citrine] effect 重跑失败(#{e.class}: #{e.message}),已跳过;" \
61
+ "同批其余 effect 不受影响"
62
+ end
63
+ end
64
+ end
65
+ @queue = nil
66
+ @queued = nil
67
+ raise first_error if first_error
68
+ end
69
+ end
70
+ end
71
+
72
+ # 细粒度响应信号(决策 #3 核心):
73
+ # 读取时向当前 Effect 登记依赖;写入时同步通知订阅者重跑。
74
+ # 唯一读写通道是 #get / #set——绕过它们(如直接改内部数组)不会触发更新,
75
+ # v1 集合为整体替换语义。
76
+ class Signal
77
+ # 可以给块:块即**惰性初值**,第一次 get 时才求值(只求一次)。
78
+ # 与 state 宏里 `init:` 的区别:这里块在**定义处**的词法作用域执行(普通 Proc 语义),
79
+ # 组件的 `state ... do ... end` 则在组件实例上求值。
80
+ def initialize(value = nil, &init)
81
+ @value = value
82
+ @init = init
83
+ @subs = []
84
+ end
85
+
86
+ def get
87
+ effect = Effect.current
88
+ effect&.depend(self)
89
+ run_init if @init
90
+ @value
91
+ end
92
+
93
+ # 读值但**不订阅**:只想拿一份快照、不想让当前 Effect 依赖它时用(MobX 的 untracked)。
94
+ # get 一旦落在块/Effect 里就会建立依赖——"订阅"与"取值"耦合在一起,
95
+ # 想两者分开时就需要这个显式出口。
96
+ def peek
97
+ run_init if @init
98
+ @value
99
+ end
100
+
101
+ def set(new_value)
102
+ @init = nil # 显式写入过就不再是"未初始化"
103
+ return self if new_value == @value
104
+
105
+ @value = new_value
106
+ broadcast
107
+ self
108
+ end
109
+
110
+ # 强制广播:跳过相等短路(S1-11)。用于"值按引用共享、内容被就地改写"的
111
+ # 场景——对象还是同一个,相等检查会误判为没变。
112
+ def set!(new_value)
113
+ @init = nil
114
+ @value = new_value
115
+ broadcast
116
+ self
117
+ end
118
+
119
+ def broadcast
120
+ if Scheduler.batching?
121
+ # 批量窗口(S1-1):只入队不广播——去重后 flush 一次跑完
122
+ @subs.dup.each { |effect| Scheduler.schedule(effect) }
123
+ else
124
+ @subs.dup.each(&:run)
125
+ end
126
+ self
127
+ end
128
+
129
+ private :broadcast # 只经 set / set! 触发,不允许外部绕过值更新手动广播
130
+
131
+ # 惰性初值是否还没求过(诊断用)
132
+ def lazy? = !@init.nil?
133
+
134
+ # 静默换值:不广播、不触发任何订阅者。用于"每次重传都是新对象的回调类 prop"
135
+ # (S1-2):换引用不算变更,与 keyed 复用忽略 Proc 的口径一致。
136
+ def replace(new_value)
137
+ @init = nil
138
+ @value = new_value
139
+ self
140
+ end
141
+
142
+ # 以下两个方法供 Effect 内部使用
143
+
144
+ def subscribe(effect)
145
+ @subs << effect unless @subs.include?(effect)
146
+ end
147
+
148
+ def unsubscribe(effect)
149
+ @subs.delete(effect)
150
+ end
151
+
152
+ private
153
+
154
+ def run_init
155
+ init = @init
156
+ @init = nil
157
+ @value = init.call
158
+ end
159
+ end
160
+
161
+ # Effect:block 内读取的信号自动成为依赖,任一依赖变化时重跑整个 block。
162
+ # 每次重跑前先解除全部旧依赖、按新一次读取重建(MobX 式追踪),
163
+ # 因此条件分支切换后,未再读取的信号不再触发本 Effect。
164
+ class Effect
165
+ class << self
166
+ def stack
167
+ @stack ||= []
168
+ end
169
+
170
+ def current
171
+ stack.last
172
+ end
173
+
174
+ # track_cleanup: 块返回 Proc 时把它当作 cleanup——每次重跑前与 dispose 时
175
+ # 各执行一次(S1-8 的 effect 宏用;其余 Effect 不启用,避免把普通返回值误当清理)。
176
+ def create(track_cleanup: false, &block)
177
+ effect = new(block, track_cleanup)
178
+ effect.run
179
+ effect
180
+ end
181
+ end
182
+
183
+ def initialize(block, track_cleanup = false)
184
+ @block = block
185
+ @track_cleanup = track_cleanup
186
+ @deps = []
187
+ end
188
+
189
+ def run
190
+ return self if @deps.nil? # 已 dispose 的 effect 保持惰性(广播快照中可能仍被迭代到)
191
+
192
+ call_cleanup # 重跑前先清上一轮申请的资源(订阅还在,清理时读得到新鲜值)
193
+ release_deps
194
+ self.class.stack.push(self)
195
+ begin
196
+ result = @block.call
197
+ @cleanup = result if @track_cleanup && result.is_a?(Proc)
198
+ ensure
199
+ self.class.stack.pop
200
+ end
201
+ self
202
+ end
203
+
204
+ # 永久停用:从所有依赖中移除,block 不再执行(节点销毁时由渲染器调用)。
205
+ # 幂等:同一 effect 可能被销毁两次(自身重跑中 dispose 兄弟节点后又被快照迭代)。
206
+ def dispose
207
+ return if @deps.nil?
208
+
209
+ call_cleanup # 卸载路径的清理:跑完才释放订阅(与 computed 释放顺序同口径)
210
+ release_deps
211
+ @block = nil
212
+ @deps = nil
213
+ end
214
+
215
+ def depend(signal)
216
+ return if @deps.nil? # 块运行中自 dispose:依赖表已清空,不再登记(原 nil.any? 崩溃点)
217
+ return if @deps.any? { |d| d.equal?(signal) } # 同一信号读多次只记一条依赖边
218
+
219
+ @deps << signal
220
+ signal.subscribe(self)
221
+ end
222
+
223
+ private
224
+
225
+ def release_deps
226
+ @deps.each { |s| s.unsubscribe(self) }
227
+ @deps.clear
228
+ end
229
+
230
+ def call_cleanup
231
+ cleanup = @cleanup
232
+ @cleanup = nil
233
+ cleanup&.call
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "base64"
5
+
6
+ module Citrine
7
+ # source map 还原(T-B3 后半):把 Opal 产物的 JS 抛错位置还原到 .rb 文件与行号。
8
+ #
9
+ # 支持两种 map 形态:
10
+ # - 普通 map:顶层 mappings/sources
11
+ # - indexed map:Opal 产出的 sections 内嵌多段 map
12
+ #
13
+ # 用法(拿到未捕获异常的 JS 行号后):
14
+ # map = Citrine::SourceMap.load("counter.js")
15
+ # loc = Citrine::SourceMap.locate(map, js_line, js_column)
16
+ # loc # => { source: "counter.rb", line: 12, column: 5 }
17
+ class SourceMap
18
+ B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
19
+ .chars.each_with_index.to_h.freeze
20
+
21
+ attr_reader :raw
22
+
23
+ # 从 .js 文件读取内联 base64 map(Opal 默认产物形态)
24
+ def self.load(js_path)
25
+ body = File.read(js_path)
26
+ match = body.match(/sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+\/=]+)/)
27
+ raise ArgumentError, "#{js_path} 内没有内联 source map" unless match
28
+
29
+ new(JSON.parse(Base64.decode64(match[1])))
30
+ end
31
+
32
+ def initialize(raw)
33
+ @raw = raw
34
+ end
35
+
36
+ # 把生成的 JS 位置(1-based 行)还原到原始 .rb 位置。
37
+ # 返回 { source:, line:, column: } 或 nil(该位置无映射信息)。
38
+ def locate(line, column = 0)
39
+ line0 = line - 1 # 内部统一 0-based 生成行(spec 的 section offset 即 0-based)
40
+ if raw["sections"]
41
+ section, offset = find_section(line0, column)
42
+ return nil unless section
43
+
44
+ off_line, off_col = offset
45
+ locate_in(section["map"], line0 - off_line + 1, column - off_col)
46
+ else
47
+ locate_in(raw, line, column)
48
+ end
49
+ end
50
+
51
+ # map 覆盖的所有原始文件
52
+ def sources
53
+ if raw["sections"]
54
+ raw["sections"].flat_map { |s| s.dig("map", "sources") || [] }.compact
55
+ else
56
+ raw["sources"] || []
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ # 选取起始位置 <= 目标位置的最后一个 section(sections 按 offset 升序)。
63
+ # offset 兼容规范 hash 形态 {"line":, "column":}(部分工具也用数组)。
64
+ def find_section(line0, _column0)
65
+ best = nil
66
+ best_offset = nil
67
+ raw["sections"].each do |section|
68
+ offset = section["offset"]
69
+ off_line = offset.is_a?(Array) ? offset[0] : offset["line"].to_i
70
+ off_col = offset.is_a?(Array) ? (offset[1] || 0) : offset["column"].to_i
71
+ if off_line <= line0
72
+ best = section
73
+ best_offset = [off_line, off_col]
74
+ end
75
+ end
76
+ best ? [best, best_offset] : nil
77
+ end
78
+
79
+ def locate_in(map, line, column)
80
+ decoded = decoded_lines(map)
81
+ return nil if line <= 0 || line > decoded.size
82
+
83
+ segments = decoded[line - 1]
84
+ seg = segments.reverse.find { |(gen_col, *)| gen_col <= column }
85
+ return nil if seg.nil? || seg[1].nil?
86
+
87
+ sources = map["sources"] || []
88
+ return nil if sources[seg[1]].nil?
89
+
90
+ # src_line/src_col 以 0 计,行号转 1-based
91
+ { source: sources[seg[1]], line: seg[2] + 1, column: seg[3] }
92
+ end
93
+
94
+ # 全量预解码 mappings:生成列每行重置,源索引/源行/源列跨行累积(spec 语义)。
95
+ # 每行产出 [gen_col, src_idx, src_line, src_col] 段(1 字段段仅推进 gen_col)。
96
+ def decoded_lines(map)
97
+ @decoded_cache ||= {}
98
+ @decoded_cache[map] ||= begin
99
+ src_idx = 0
100
+ src_line = 0
101
+ src_col = 0
102
+ (map["mappings"] || "").split(";").map do |line|
103
+ gen_col = 0
104
+ line.split(",").filter_map do |segment|
105
+ next if segment.empty?
106
+
107
+ fields = decode_vlq(segment)
108
+ gen_col += fields[0]
109
+ if fields.size >= 4
110
+ src_idx += fields[1]
111
+ src_line += fields[2]
112
+ src_col += fields[3]
113
+ [gen_col, src_idx, src_line, src_col]
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ # Base64 VLQ 解码
121
+ def decode_vlq(segment)
122
+ values = []
123
+ shift = 0
124
+ value = 0
125
+ segment.each_char do |ch|
126
+ digit = B64[ch] or raise ArgumentError, "非法 base64 VLQ 字符: #{ch.inspect}"
127
+ cont = (digit & 32) != 0
128
+ digit &= 31
129
+ value += digit << shift
130
+ if cont
131
+ shift += 5
132
+ else
133
+ negative = (value & 1) == 1
134
+ value >>= 1
135
+ values << (negative ? -value : value)
136
+ value = 0
137
+ shift = 0
138
+ end
139
+ end
140
+ values
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "citrine"
4
+ require "citrine/renderer"
5
+
6
+ module Citrine
7
+ # render-to-string 渲染器:纯 CRuby 可用(SSR / 单测 / 快照)。
8
+ #
9
+ # 一次性渲染:不建立 Effect 订阅(reactive? = false);
10
+ # 事件处理器不序列化(SSR 交互不在本渲染器范围)。
11
+ class StringRenderer < Renderer
12
+ class << self
13
+ def render(component)
14
+ new.mount_component(component, nil).children.map(&:dom).join
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def reactive?
21
+ false
22
+ end
23
+
24
+ def setup_root(root, _element)
25
+ root.dom = ""
26
+ end
27
+
28
+ def create_dom(_node)
29
+ ""
30
+ end
31
+
32
+ def apply_props(_node); end
33
+
34
+ def attach(_node, _parent); end
35
+
36
+ def detach(_node); end
37
+
38
+ def set_text(node, text)
39
+ node.text = text
40
+ end
41
+
42
+ def setup_widget(_node); end
43
+
44
+ # SSR 无宿主概念:portal 内容按逻辑位置内联输出
45
+ def resolve_portal_host(_target)
46
+ ""
47
+ end
48
+
49
+ def finalize(node)
50
+ if node.type == :fragment || node.type == :portal || node.type == :suspense
51
+ # 透明容器(S1-4 / S1-5 / S1-10):SSR 侧都按子根依次拼接
52
+ #(SSR 无宿主概念,portal 内容按逻辑位置内联输出;suspense 只输出当前分支)
53
+ node.dom = node.children.map(&:dom).join
54
+ node.dom += escape_html(node.text) if node.text
55
+ return
56
+ end
57
+ return if node.type == :root
58
+
59
+ node.dom = serialize(node)
60
+ end
61
+
62
+ def serialize(node)
63
+ tag = TAGS[node.type] || node.type.to_s
64
+ attrs = attributes(node)
65
+ return "<#{tag}#{attrs}>" if VOID.include?(node.type)
66
+
67
+ inner = node.children.map(&:dom).join
68
+ inner += escape_html(node.text) if node.text
69
+ "<#{tag}#{attrs}>#{inner}</#{tag}>"
70
+ end
71
+
72
+ def attributes(node)
73
+ out = []
74
+ # 响应式属性在 SSR 侧只求值一次(无订阅、无重跑,与 DOM 输出保持一致)
75
+ css_class = prop_value(node, node.props[:css_class])
76
+ # A7:数组形式与 DOM className 同口径——空格 join,不再渲染成 Ruby 字面量
77
+ css_class = css_class.join(" ") if css_class.is_a?(Array)
78
+ # 属性值必须转义:值里出现引号会提前闭合属性,把数据变成新属性(属性注入)。
79
+ out << %(class="#{escape_html(css_class)}") if css_class
80
+ case node.type
81
+ when :text_input
82
+ out << %(type="#{escape_html(node.props[:type] || "text")}")
83
+ if (placeholder = prop_value(node, node.props[:placeholder]))
84
+ out << %(placeholder="#{escape_html(placeholder)}")
85
+ end
86
+ value = node.props[:value]
87
+ value = value.get if value.is_a?(Signal)
88
+ out << %(value="#{escape_html(value)}") if value && value != ""
89
+ when :check_box
90
+ out << 'type="checkbox"'
91
+ checked = node.props[:checked]
92
+ checked = checked.get if checked.is_a?(Signal)
93
+ out << "checked" if checked
94
+ end
95
+ # S2-2:未消费属性原样透传,口径与 DOM 一致(kebab-case;true → 空值属性;
96
+ # false / nil 不输出)。值同样要转义——理由与 class 相同。
97
+ passthrough_props(node).each do |name, value|
98
+ out << (value.empty? ? name : %(#{name}="#{escape_html(value)}"))
99
+ end
100
+ style = resolve_style(node)
101
+ out << %(style="#{style_css(style)}") unless style.empty?
102
+ out.empty? ? "" : " #{out.join(' ')}"
103
+ end
104
+
105
+ def style_css(style)
106
+ # 样式键为 snake_case(决策 #10);内联 CSS 属性必须是 kebab-case。
107
+ # 值同样要转义——理由与 class 相同:属性值里的引号会提前闭合 style 属性。
108
+ style.map { |key, value| "#{Style.kebab(key)}:#{escape_html(value)}" }.join(";")
109
+ end
110
+
111
+ def escape_html(text)
112
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;")
113
+ .gsub(">", "&gt;").gsub('"', "&quot;")
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Citrine
4
+ # 样式键归一化(决策 #10):
5
+ # - API 层统一 snake_case(Ruby 惯例,与 on_click 等事件命名同风格)
6
+ # - 兼容 camelCase 输入(fontSize 与 font_size 等价)
7
+ # - 值中的 Symbol 自动转 CSS 连字符形式(:line_through → "line-through")
8
+ # 各渲染器在边界自行编译:DOM → camelCase 属性;CSS 字符串 → kebab-case;
9
+ # Canvas 直接以 snake_case 消费。
10
+ module Style
11
+ module_function
12
+
13
+ # 有数值默认补 px 的属性(显式字符串单位不受影响)。
14
+ # font_size 等排版键在此的原因:CSS 里 `font-size:14` 是非法声明(浏览器整条丢弃),
15
+ # SSR 按值直拼会输出非法 CSS——数值必须推断成 "14px" 才与 DOM 侧一致。
16
+ PX_PROPERTIES = %i[
17
+ width height min_width min_height max_width max_height
18
+ border_radius top left right bottom inset
19
+ padding padding_left padding_right padding_top padding_bottom
20
+ margin margin_left margin_right margin_top margin_bottom
21
+ font_size letter_spacing word_spacing text_indent
22
+ gap row_gap column_gap outline_width outline_offset
23
+ ].freeze
24
+
25
+ # 数值语义上无单位的属性
26
+ UNITLESS_PROPERTIES = %i[
27
+ flex flex_grow flex_shrink order opacity z_index line_height
28
+ font_weight column_count zoom
29
+ ].freeze
30
+
31
+ def normalize(style)
32
+ return {} unless style
33
+
34
+ style.each_with_object({}) do |(key, value), out|
35
+ key = underscore(key)
36
+ value = resolve_theme_ref(value)
37
+ value = normalize_value(value)
38
+ next if value.nil? # F17:nil 值剔除而非输出非法 CSS
39
+ if value.is_a?(Numeric) && PX_PROPERTIES.include?(key) && !UNITLESS_PROPERTIES.include?(key)
40
+ value = "#{value}px"
41
+ end
42
+ out[key] = value
43
+ end
44
+ end
45
+
46
+ # 主题 token 引用(S2-5)→ 主题值。token 值还可以再引用 token
47
+ # (有深度上限防循环引用);解析后的数值走同一套 px 推断
48
+ def resolve_theme_ref(value, depth = 0)
49
+ return value unless value.is_a?(Theme::Ref)
50
+ raise ArgumentError, "主题 token 嵌套过深(疑似循环引用)" if depth > 8
51
+
52
+ resolve_theme_ref(Theme.resolve!(value), depth + 1)
53
+ end
54
+
55
+ # fontSize / font_size / :fontSize → :font_size;kebab-case("font-size")同归一。
56
+ # P2:per-key memo——normalize 对每个键每次渲染都跑转换,缓存后同键零分配。
57
+ def underscore(key)
58
+ @underscore_cache ||= {}
59
+ @underscore_cache[key] ||= key.to_s.gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }
60
+ .gsub("-", "_").downcase.to_sym
61
+ end
62
+
63
+ # font_size → fontSize(DOM style 属性赋值用)
64
+ def camel(key)
65
+ @camel_cache ||= {}
66
+ @camel_cache[key] ||= key.to_s.gsub(/_([a-zA-Z0-9])/) { Regexp.last_match(1).upcase }
67
+ end
68
+
69
+ # font_size → font-size(内联 CSS 字符串用)
70
+ def kebab(key)
71
+ @kebab_cache ||= {}
72
+ @kebab_cache[key] ||= key.to_s.tr("_", "-")
73
+ end
74
+
75
+ def normalize_value(value)
76
+ value.is_a?(Symbol) ? value.to_s.tr("_", "-") : value
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Citrine
4
+ # 主题 token 与全局样式资产(S2-5 后半):
5
+ #
6
+ # Citrine.theme(color_primary: "#667eea", spacing_md: 12)
7
+ # Citrine.css("styles.css") # 声明样式表:dev server 注入 <link>,打包器随包复制
8
+ # Citrine.css_text = <<~CSS # 媒体查询 / 伪类等内联样式表达不了的逃生舱
9
+ # .card:hover { background: #f1f5f9; }
10
+ # CSS
11
+ #
12
+ # style 里用 Citrine.token(:name) 引用 token,归一化时解析:
13
+ # label(style: { background: Citrine.token(:color_primary),
14
+ # padding: Citrine.token(:spacing_md) })
15
+ # 数值 token 走同一套 px 推断;未定义的 token 当场报错(不静默变成裸字符串)。
16
+ module Theme
17
+ # token 引用:Style.normalize 遇到它时查主题注册表
18
+ class Ref
19
+ attr_reader :name
20
+
21
+ def initialize(name)
22
+ @name = name.to_sym
23
+ end
24
+
25
+ def inspect = "#<Citrine::Theme::Ref #{@name}>"
26
+ def to_s = "token(#{@name})"
27
+ end
28
+
29
+ class << self
30
+ def registry
31
+ @registry ||= {}
32
+ end
33
+
34
+ def define(**tokens)
35
+ registry.merge!(tokens)
36
+ self
37
+ end
38
+
39
+ # 解析 token 引用;未定义的 token 属于样式错误,当场报
40
+ def resolve!(ref)
41
+ raise ArgumentError, "未定义的主题 token: #{ref.name}(先 Citrine.theme(#{ref.name}: ...))" unless registry.key?(ref.name)
42
+
43
+ registry[ref.name]
44
+ end
45
+ end
46
+ end
47
+
48
+ class << self
49
+ # 定义 / 读取主题 token:Citrine.theme(color_primary: "#667eea");
50
+ # 无参调用返回注册表快照
51
+ def theme(**tokens)
52
+ return Theme.registry.dup if tokens.empty?
53
+
54
+ Theme.define(**tokens)
55
+ end
56
+
57
+ # style 里引用主题 token
58
+ def token(name)
59
+ Theme::Ref.new(name)
60
+ end
61
+
62
+ # 声明样式表文件(相对应用根):dev server 注入 <link>、打包器随包复制
63
+ def css(*files)
64
+ css_files.concat(files.map(&:to_s))
65
+ self
66
+ end
67
+
68
+ def css_files
69
+ @css_files ||= []
70
+ end
71
+
72
+ attr_writer :css_files
73
+
74
+ # 自定义 CSS 文本(媒体查询 / 伪类等内联样式表达不了的东西的逃生舱):
75
+ # dev server 注入 <style>;SSR / 模板侧经 stylesheet_tags 输出
76
+ def css_text
77
+ @css_text
78
+ end
79
+
80
+ def css_text=(text)
81
+ @css_text = text.to_s
82
+ end
83
+
84
+ # SSR / 模板侧:产出 <link> / <style> 标签串
85
+ def stylesheet_tags
86
+ tags = css_files.map { |f| %(<link rel="stylesheet" href="/#{f}">) }
87
+ tags << "<style>#{css_text}</style>" if css_text && !css_text.empty?
88
+ tags.join("\n")
89
+ end
90
+
91
+ # 测试与多页面隔离用:清空样式资产与主题
92
+ def reset_style_assets!
93
+ @css_files = []
94
+ @css_text = nil
95
+ Theme.registry.clear
96
+ self
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Citrine
4
+ VERSION = "0.2.0"
5
+ end