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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +249 -0
- data/bin/citrine +39 -0
- data/desktop/main.swift +44 -0
- data/lib/citrine/browser.rb +11 -0
- data/lib/citrine/canvas.rb +503 -0
- data/lib/citrine/component.rb +692 -0
- data/lib/citrine/debug.rb +109 -0
- data/lib/citrine/dev_server.rb +305 -0
- data/lib/citrine/dom.rb +292 -0
- data/lib/citrine/event.rb +31 -0
- data/lib/citrine/key_event.rb +39 -0
- data/lib/citrine/list_signal.rb +354 -0
- data/lib/citrine/node.rb +36 -0
- data/lib/citrine/num.rb +75 -0
- data/lib/citrine/packager.rb +129 -0
- data/lib/citrine/reactive.rb +40 -0
- data/lib/citrine/renderer.rb +828 -0
- data/lib/citrine/signal.rb +236 -0
- data/lib/citrine/sourcemap.rb +143 -0
- data/lib/citrine/string_renderer.rb +116 -0
- data/lib/citrine/style.rb +79 -0
- data/lib/citrine/theme.rb +99 -0
- data/lib/citrine/version.rb +5 -0
- data/lib/citrine.rb +92 -0
- data/lib/rubocop/cop/citrine/no_raw_ivar_assignment.rb +75 -0
- metadata +157 -0
|
@@ -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("&", "&").gsub("<", "<")
|
|
113
|
+
.gsub(">", ">").gsub('"', """)
|
|
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
|