caset 1.0.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/README.md +180 -0
- data/bin/cm +15 -0
- data/bin/cml +15 -0
- data/doc/MANUAL.md +100 -0
- data/doc/sample.csv +12 -0
- data/doc/sample.md +60 -0
- data/lib/caset/gen_bdd.rb +121 -0
- data/lib/caset/gen_caset.rb +41 -0
- data/lib/caset/gen_pytest.rb +211 -0
- data/lib/caset/gen_spec.rb +36 -0
- data/lib/caset/version.rb +3 -0
- data/lib/caset.rb +272 -0
- data/lib/casetdown/casetable.rb +121 -0
- data/lib/casetdown/casetcode.rb +345 -0
- data/lib/casetdown/casetdoc.rb +241 -0
- data/lib/casetdown/casetter.rb +141 -0
- data/lib/casetdown/cli.rb +129 -0
- data/lib/casetdown/live.rb +102 -0
- data/lib/casetdown/support/api_app.rb +127 -0
- data/lib/casetdown/support/endata.rb +197 -0
- data/lib/casetdown/support/simtab.rb +372 -0
- data/lib/casetdown/support/text_abstract.rb +124 -0
- data/lib/casetdown/support/tintext.rb +141 -0
- data/lib/casetdown.rb +48 -0
- metadata +79 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#coding:utf-8
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
#------------------------------------------------------------------------------
|
|
5
|
+
# 代码块执行引擎与语言适配器
|
|
6
|
+
#------------------------------------------------------------------------------
|
|
7
|
+
module CasetDown
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# 将 HTML 实体转义还原为原始字符。
|
|
11
|
+
#
|
|
12
|
+
# 处理 > → > 和 < → < 的转换。
|
|
13
|
+
#
|
|
14
|
+
# @param text [String] 包含 >、< 的文本
|
|
15
|
+
# @return [String] 还原后的原始文本
|
|
16
|
+
def textchange text
|
|
17
|
+
text.gsub('>','>').gsub('<','<')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# 将输出文本包装为 Markdown 代码块格式。
|
|
21
|
+
#
|
|
22
|
+
# 生成格式:```shell\n#output>id\ntext\n```
|
|
23
|
+
#
|
|
24
|
+
# @param text [String] 输出内容
|
|
25
|
+
# @param id [String] 输出标识符
|
|
26
|
+
# @param tag [String] 标签前缀,默认为 'output'
|
|
27
|
+
# @return [String] 包装后的 Markdown 代码块字符串
|
|
28
|
+
def wrap_output text, id, tag='output'
|
|
29
|
+
output_start = "```shell\n##{tag}>#{id}"
|
|
30
|
+
output_finish= "```"
|
|
31
|
+
return [output_start, text, output_finish].join("\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 在文本中搜索特定输出标记段落。
|
|
35
|
+
#
|
|
36
|
+
# 匹配 ```shell\n#output>id ... ``` 格式的代码块。
|
|
37
|
+
# 注意:TODO 存在 #output>path 和 #output> path 的识别问题。
|
|
38
|
+
#
|
|
39
|
+
# @param text [String] 完整文档文本
|
|
40
|
+
# @param id [String] 输出标识符
|
|
41
|
+
# @param tag [String] 标签前缀,默认为 'output'
|
|
42
|
+
# @return [Array<String>] 匹配到的输出内容列表
|
|
43
|
+
def search_output text, id, tag='output'
|
|
44
|
+
# [TODO] REFACTORING for recognition between '#output>path' and '#output> path'
|
|
45
|
+
output_start = "```shell\n##{tag}>#{id}"
|
|
46
|
+
output_finish= "```"
|
|
47
|
+
match = TextAbstract.match_paragraph text, output_start, output_finish
|
|
48
|
+
return match.map{|m|[output_start, m, output_finish].join}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 从代码块头部魔法注释提取元数据。
|
|
52
|
+
#
|
|
53
|
+
# 支持的魔法注释及其含义:
|
|
54
|
+
# #src> / #script> → interpreter(解释器类型)
|
|
55
|
+
# #ns> / #namespace> → namespace(命名空间)
|
|
56
|
+
# #conf> / #config> → config(配置文件)
|
|
57
|
+
# #dst> / #target> → target(目标文件)
|
|
58
|
+
# #flow> / #step> → flow(流程标识)
|
|
59
|
+
# #seg> / #segment> → segment(分段标识)
|
|
60
|
+
# #in> / #input> → input(输入文件)
|
|
61
|
+
# #out> / #output> → output(输出文件)
|
|
62
|
+
# #row> → rows(行范围,支持 ~ 展开为 ..)
|
|
63
|
+
# #col> → cols(列选择,支持 ~ 展开为 ..)
|
|
64
|
+
#
|
|
65
|
+
# @param code [Hash] 代码块结构,必须包含 :code 和 :class 键
|
|
66
|
+
# @option code [String] :code 代码文本内容
|
|
67
|
+
# @option code [String, Symbol] :class 代码语言类,默认 :default
|
|
68
|
+
# @return [Array] 元数据数组
|
|
69
|
+
# [0] interpreter [String, Symbol] 解释器类型
|
|
70
|
+
# [1] namespace [String, Symbol] 命名空间
|
|
71
|
+
# [2] config [String, Symbol] 配置文件
|
|
72
|
+
# [3] target [String, Symbol] 目标文件
|
|
73
|
+
# [4] flow [String, Symbol] 流程标识
|
|
74
|
+
# [5] segment [String, Symbol] 分段标识
|
|
75
|
+
# [6] input [String, Symbol] 输入文件
|
|
76
|
+
# [7] output [String, Symbol] 输出文件
|
|
77
|
+
# [8] rows [Array, Symbol] 行范围数组或 :rows
|
|
78
|
+
# [9] cols [Array, Symbol] 列选择数组或 :cols
|
|
79
|
+
def magic_head code
|
|
80
|
+
states = textchange(code[:code]).split("\n")
|
|
81
|
+
interpreter = code[:class]
|
|
82
|
+
script = states.find{|s|s[0..4]=='#src>'||s[0..7]=='#script>'}.to_s.split('>')[1]
|
|
83
|
+
interpreter = script if script # :default
|
|
84
|
+
|
|
85
|
+
namespace = states.find{|s|s[0..3]=='#ns>'||s[0..10]=='#namespace>'}.to_s.split('>')[1] || :ns
|
|
86
|
+
config = states.find{|s|s[0..5]=='#conf>'||s[0..7]=='#config>'}.to_s.split('>')[1] || :conf
|
|
87
|
+
target = states.find{|s|s[0..4]=='#dst>'||s[0..7]=='#target>'}.to_s.split('>')[1]
|
|
88
|
+
|
|
89
|
+
flow = states.find{|s|s[0..5]=='#flow>'||s[0..5]=='#step>'}.to_s.split('>')[1] || :flow
|
|
90
|
+
segment = states.find{|s|s[0..4]=='#seg>'||s[0..8]=='#segment>'}.to_s.split('>')[1] || :seg
|
|
91
|
+
|
|
92
|
+
input = states.find{|s|s[0..3]=='#in>'||s[0..6]=='#input>'}.to_s.split('>')[1] || :in
|
|
93
|
+
output = states.find{|s|s[0..4]=='#out>'||s[0..7]=='#output>'}.to_s.split('>')[1] || :out
|
|
94
|
+
|
|
95
|
+
[interpreter, namespace, target, flow, segment, input, output].each{|tag|tag.strip! if tag.instance_of?(String)}
|
|
96
|
+
|
|
97
|
+
rows = if row = states.find{|s|s[0..4]=='#row>'}.to_s.split('>')[1]
|
|
98
|
+
row.gsub('~','..').split(',').map{|r|eval(r)}
|
|
99
|
+
else
|
|
100
|
+
:rows
|
|
101
|
+
end
|
|
102
|
+
cols = if col = states.find{|s|s[0..4]=='#col>'}.to_s.split('>')[1]
|
|
103
|
+
col.split(',').map{|c|
|
|
104
|
+
c.split('').inject(true){|f,chr|
|
|
105
|
+
f&&(('0'..'9').to_a+['-','~']).include?(chr)
|
|
106
|
+
} ? eval(c.gsub('~','..')) : c.strip
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
:cols
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return interpreter, namespace, config, target, flow, segment, input, output, rows, cols
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# 从文档中提取所有代码块,按元数据分组为哈希表。
|
|
116
|
+
#
|
|
117
|
+
# 过滤掉魔法注释行,只保留实际代码内容。
|
|
118
|
+
#
|
|
119
|
+
# @param cds [Hash] 解析结果,包含文档结构
|
|
120
|
+
# @return [Hash] 代码块映射表
|
|
121
|
+
# 键:[object_id, interpreter, namespace, config, target, [rows, cols], flow, segment, input, output]
|
|
122
|
+
# 值:String 代码内容(已过滤魔法注释)
|
|
123
|
+
def search_codes cds
|
|
124
|
+
codes = CasetDown::Check.all(cds).select{|cd|cd[:name]=='code'}
|
|
125
|
+
|
|
126
|
+
table = codes.inject({}) do|table, code|
|
|
127
|
+
interpreter, namespace, config, target, flow, segment, input, output, rows, cols = magic_head code
|
|
128
|
+
|
|
129
|
+
content = textchange(code[:code]).split("\n")[1..-1].select{|s|
|
|
130
|
+
!['#ns>','#in>'].include?(s[0..3]) &&
|
|
131
|
+
!['#out>','#src>','#dst>','#seg>','#row>','#col>'].include?(s[0..4]) &&
|
|
132
|
+
!['#flow>','#step>','#conf>'].include?(s[0..5]) &&
|
|
133
|
+
!['#input>'].include?(s[0..6]) &&
|
|
134
|
+
!['#script>','#target>','#output>','#config>'].include?(s[0..7]) &&
|
|
135
|
+
!['#segment>'].include?(s[0..8]) &&
|
|
136
|
+
!['#namespace>'].include?(s[0..10])
|
|
137
|
+
}.join("\n")
|
|
138
|
+
table[[code.object_id, interpreter, namespace, config, target, [rows,cols], flow, segment, input, output]] = content
|
|
139
|
+
table
|
|
140
|
+
end
|
|
141
|
+
return table
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# 执行文档中所有代码块,支持多种编程语言。
|
|
145
|
+
#
|
|
146
|
+
# 支持解释器:shell/bash, ruby, erlang/erl, python3/python, lms, lmx
|
|
147
|
+
# 全局配置按代码块顺序累积生效($_tmp_global_)。
|
|
148
|
+
#
|
|
149
|
+
# @param cds [Hash] 解析结果
|
|
150
|
+
# @return [Hash] 执行结果映射
|
|
151
|
+
# 键:input [String, Symbol] 输入标识
|
|
152
|
+
# 值:running_output [String] 代码执行输出
|
|
153
|
+
# @note Windows 平台(mingw)shell 执行暂为空实现
|
|
154
|
+
def run cds
|
|
155
|
+
table = CasetDown.search_codes(cds)
|
|
156
|
+
$_tmp_global_ = {}
|
|
157
|
+
$_scr_head_ = ['#coding:utf-8','require "casetdown"'].join("\n")
|
|
158
|
+
result = {}
|
|
159
|
+
table.each do|key, text|
|
|
160
|
+
oid, interpreter, ns, conf, target, range, flow, seg, input, output = key
|
|
161
|
+
unless conf==:conf # 注意全局配置累积生效,所以有先后顺序
|
|
162
|
+
$_tmp_global_.merge!(
|
|
163
|
+
(File.exist?(conf) ? YAML.load(File.read conf) : {}) ||
|
|
164
|
+
(File.exist?("#{conf}.yml") ? YAML.load(File.read "#{conf}.yml") : {}) ||
|
|
165
|
+
(File.exist?("#{conf}.yaml") ? YAML.load(File.read "#{conf}.yaml") : {})
|
|
166
|
+
)
|
|
167
|
+
$_scr_tail_ = "\n__END__\n[global@endata ~]$ruby global\n#{$_tmp_global_.to_s}"
|
|
168
|
+
$_erl_tail_ = $_tmp_global_.empty? ? '' : ToErlang.fact('global', (ToErlang.hash($_tmp_global_)) )
|
|
169
|
+
end
|
|
170
|
+
if interpreter=='shell' || interpreter==:default
|
|
171
|
+
if RUBY_PLATFORM.include?('mingw')
|
|
172
|
+
else
|
|
173
|
+
File.write "./~tmp.sh",text
|
|
174
|
+
running = `bash ./~tmp.sh`
|
|
175
|
+
File.delete "./~tmp.sh"
|
|
176
|
+
end
|
|
177
|
+
elsif interpreter=='ruby'
|
|
178
|
+
File.write "./~tmp.rb","#{$_scr_head_}\n\n#{text}\n\n#{$_scr_tail_}"
|
|
179
|
+
running = `ruby -I"#{CasetDown.home}" ./~tmp.rb`
|
|
180
|
+
File.delete "./~tmp.rb"
|
|
181
|
+
elsif interpreter=='erlang' || interpreter=='erl'
|
|
182
|
+
File.write "./_tmp.erl","-module(_tmp).\n-export([main/1]).\n\n#{text}\n\n#{$_erl_tail_}\n\nfact(UnknownMessage)->\n io:format(\"~p\",[UnknownMessage])."
|
|
183
|
+
running = `escript ./_tmp.erl`
|
|
184
|
+
File.delete "./_tmp.erl"
|
|
185
|
+
elsif interpreter=='python3' || interpreter=='python'
|
|
186
|
+
File.write "./~tmp.py",text
|
|
187
|
+
running = `python ./~tmp.py`
|
|
188
|
+
File.delete "./~tmp.py"
|
|
189
|
+
elsif interpreter=='lms'
|
|
190
|
+
running = `lms "#{text}"`
|
|
191
|
+
elsif interpreter=='lmx'
|
|
192
|
+
running = `lmx "#{text}"`
|
|
193
|
+
else
|
|
194
|
+
end
|
|
195
|
+
result[input] = running
|
|
196
|
+
end
|
|
197
|
+
return result
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# 执行代码块并将结果回写到 Markdown 文档。
|
|
201
|
+
#
|
|
202
|
+
# 支持将执行结果替换回原始文档中的 #output 标记位置。
|
|
203
|
+
#
|
|
204
|
+
# @param cds [Hash] 解析结果
|
|
205
|
+
# @param option [Hash] 控制选项
|
|
206
|
+
# @option option [String] :stream 覆盖原始文档文本
|
|
207
|
+
# @option option [String] :data 附加到 Ruby 脚本尾部($_scr_tail_)
|
|
208
|
+
# @option option [String] :fact 附加到 Erlang 脚本尾部($_erl_tail_)
|
|
209
|
+
# @option option [Array<Symbol>] :file 文件控制标志
|
|
210
|
+
# - :input 保存输入脚本到文件
|
|
211
|
+
# - :output 保存执行输出到文件
|
|
212
|
+
# - :all 保存输入和输出
|
|
213
|
+
# - :debug 保留临时文件不删除
|
|
214
|
+
# - :rendfile 保存为带时间戳的新文件
|
|
215
|
+
# - :preview 保存为 preview 版本文件
|
|
216
|
+
# @return [String] 回写后的完整文档文本
|
|
217
|
+
# @note :file 为 [] 时只执行不保存文件
|
|
218
|
+
def run_to_file cds,option={stream: nil, data: nil, fact: nil,file:[]} # empty, :input, :output, :all
|
|
219
|
+
$_tmp_global_ = {}
|
|
220
|
+
$_scr_head_ = ['#coding:utf-8','require "casetdown"'].join("\n")
|
|
221
|
+
$_scr_tail_ = option[:data] ? option[:data] : ''
|
|
222
|
+
$_erl_tail_ = option[:fact] ? option[:fact] : ''
|
|
223
|
+
|
|
224
|
+
table = CasetDown.search_codes(cds)
|
|
225
|
+
result = {}
|
|
226
|
+
table.each do|key, text|
|
|
227
|
+
oid, interpreter, ns, conf, target, range, flow, seg, input, output = key
|
|
228
|
+
unless conf==:conf # 注意全局配置累积生效,所以有先后顺序
|
|
229
|
+
$_tmp_global_.merge!(
|
|
230
|
+
(File.exist?(conf) ? YAML.load(File.read conf) : {}) ||
|
|
231
|
+
(File.exist?("#{conf}.yml") ? YAML.load(File.read "#{conf}.yml") : {}) ||
|
|
232
|
+
(File.exist?("#{conf}.yaml") ? YAML.load(File.read "#{conf}.yaml") : {})
|
|
233
|
+
)
|
|
234
|
+
$_scr_tail_ = $_scr_tail_.empty? ? "\n__END__\n[global@endata ~]$ruby global\n#{$_tmp_global_.to_s}" : "#{$_scr_tail_}\n[global@endata ~]$ruby global\n#{$_tmp_global_.to_s}"
|
|
235
|
+
$_erl_tail_ = $_tmp_global_.empty? ? $_erl_tail_ : $_erl_tail_+"\n\n"+ToErlang.fact('global', (ToErlang.hash($_tmp_global_)) )
|
|
236
|
+
end
|
|
237
|
+
if interpreter==:default
|
|
238
|
+
# NOTHING
|
|
239
|
+
elsif interpreter=='shell' || interpreter=='bash'
|
|
240
|
+
if RUBY_PLATFORM.include?('mingw')
|
|
241
|
+
else
|
|
242
|
+
File.write "./~tmp.sh",text
|
|
243
|
+
running = `bash ./~tmp.sh`
|
|
244
|
+
File.delete "./~tmp.sh" unless option[:file].include?(:debug)
|
|
245
|
+
end
|
|
246
|
+
elsif interpreter=='ruby'
|
|
247
|
+
File.write "./~tmp.rb","#{$_scr_head_}\n\n#{text}\n\n#{$_scr_tail_}"
|
|
248
|
+
running = `ruby -I"#{CasetDown.home}" ./~tmp.rb`
|
|
249
|
+
File.delete "./~tmp.rb" unless option[:file].include?(:debug)
|
|
250
|
+
elsif interpreter=='erlang' || interpreter=='erl'
|
|
251
|
+
File.write "./_tmp.erl","-module(_tmp).\n-export([main/1]).\n\n#{text}\n\n#{$_erl_tail_}\n\nfact(UnknownMessage)->\n io:format(\"~p\",[UnknownMessage])."
|
|
252
|
+
running = `escript ./_tmp.erl`
|
|
253
|
+
File.delete "./_tmp.erl" unless option[:file].include?(:debug)
|
|
254
|
+
elsif interpreter=='python3' || interpreter=='python'
|
|
255
|
+
File.write "./~tmp.py",text
|
|
256
|
+
running = `python ./~tmp.py`
|
|
257
|
+
File.delete "./~tmp.py" unless option[:file].include?(:debug)
|
|
258
|
+
elsif interpreter=='lms'
|
|
259
|
+
running = `lms "#{text}"`
|
|
260
|
+
elsif interpreter=='lmx'
|
|
261
|
+
running = `lmx "#{text}"`
|
|
262
|
+
else
|
|
263
|
+
end
|
|
264
|
+
result[input] = running
|
|
265
|
+
File.write input, text if !(option[:file] & [:input, :all]).empty? && input.instance_of?(String)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
origin_text = option[:stream] || cds[:doc].text
|
|
269
|
+
result.each do|input, running| # input == output
|
|
270
|
+
File.write input, running if !(option[:file] & [:output, :all]).empty? && input.instance_of?(String)
|
|
271
|
+
origins = CasetDown.search_output(origin_text, input)
|
|
272
|
+
wrapped = CasetDown.wrap_output(running,input)
|
|
273
|
+
if origins.empty?
|
|
274
|
+
origins = CasetDown.search_output(origin_text, input, 'out')
|
|
275
|
+
wrapped = CasetDown.wrap_output(running,input, 'out')
|
|
276
|
+
end
|
|
277
|
+
origins.each do|origin|
|
|
278
|
+
# [TODO] 无法解决不同后缀的前置匹配替换问题,待修复
|
|
279
|
+
origin_text = origin_text.gsub(origin, wrapped)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
if option[:file].include?(:rendfile)
|
|
284
|
+
if option[:file].include?(:preview)
|
|
285
|
+
prvw = cds[:path].split('.')
|
|
286
|
+
prvw.insert(-2,'preview')
|
|
287
|
+
File.write prvw.join('.'), origin_text
|
|
288
|
+
else
|
|
289
|
+
pazz = cds[:path].split('.')
|
|
290
|
+
pazz.insert(-2,Time.new.strftime("%Y%m%d%H%M%S"))
|
|
291
|
+
File.write pazz.join('.'), origin_text
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
return origin_text
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Erlang 数据格式生成模块
|
|
299
|
+
module ToErlang
|
|
300
|
+
module_function
|
|
301
|
+
|
|
302
|
+
# 将二维数据转换为 Erlang 列表记录格式。
|
|
303
|
+
#
|
|
304
|
+
# 第一行作为字段名,后续行作为数据。
|
|
305
|
+
# 每个字段生成 {"field", "value"} 元组,每行生成 {..., ...} 元组。
|
|
306
|
+
#
|
|
307
|
+
# @param data [Array<Array>] 二维数据,第一行为字段名
|
|
308
|
+
# @return [String] Erlang 列表字符串
|
|
309
|
+
# @example
|
|
310
|
+
# ToErlang.table([["name", "age"], ["Alice", "30"]])
|
|
311
|
+
# # => "[{{\"name\", \"Alice\"}, {\"age\", \"30\"}}]"
|
|
312
|
+
def table data
|
|
313
|
+
fields = data.first
|
|
314
|
+
erlang_data = data.drop(1).map do |row|
|
|
315
|
+
row_data = fields.zip(row).map do |field, value|
|
|
316
|
+
"{\"#{field}\", \"#{value}\"}"
|
|
317
|
+
end.join(",")
|
|
318
|
+
"{#{row_data}}"
|
|
319
|
+
end
|
|
320
|
+
"[#{erlang_data.join(", ")}]"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# 将 Hash 转换为 Erlang 键值列表格式。
|
|
324
|
+
#
|
|
325
|
+
# @param data [Hash] 键值对数据
|
|
326
|
+
# @return [String] Erlang 键值列表字符串
|
|
327
|
+
# @example
|
|
328
|
+
# ToErlang.hash({name: "Alice", age: 30})
|
|
329
|
+
# # => "{{\"name\", \"Alice\"}, {\"age\", \"30\"}}"
|
|
330
|
+
def hash data
|
|
331
|
+
"{"+data.map{|k,v|"{\"#{k}\", \"#{v}\"}"}.join(",")+"}"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# 生成 Erlang fact 函数子句。
|
|
335
|
+
#
|
|
336
|
+
# @param name [String, Symbol] 函数名
|
|
337
|
+
# @param body [String] 函数体
|
|
338
|
+
# @return [String] Erlang 函数子句代码
|
|
339
|
+
# @example
|
|
340
|
+
# ToErlang.fact("global", "{\"key\", \"value\"}")
|
|
341
|
+
# # => "fact(\"global\")->\n {\"key\", \"value\"};"
|
|
342
|
+
def fact name, body
|
|
343
|
+
"fact(\"#{name.to_s}\")->\n #{body};"
|
|
344
|
+
end
|
|
345
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#coding:utf-8
|
|
2
|
+
|
|
3
|
+
#------------------------------------------------------------------------------
|
|
4
|
+
# 文档结构检查与解析
|
|
5
|
+
#------------------------------------------------------------------------------
|
|
6
|
+
module CasetDown
|
|
7
|
+
module Check
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# 遍历文档所有节点,按类型分发到各检查方法,收集并返回结果。
|
|
11
|
+
#
|
|
12
|
+
# 会依次调用 head, table, para, pre, list, quote 等方法处理每个节点。
|
|
13
|
+
#
|
|
14
|
+
# @param cds [Hash] 解析结果,必须包含 :src 键(Script 实例)
|
|
15
|
+
# @return [Array<Hash>] 所有节点解析后的结构化数据列表
|
|
16
|
+
# @example
|
|
17
|
+
# cds = CasetDown.load("example.md")
|
|
18
|
+
# elements = CasetDown::Check.all(cds)
|
|
19
|
+
def all cds
|
|
20
|
+
epool = cds[:src].stack.inject([]) do|epool, cd|
|
|
21
|
+
res = nil
|
|
22
|
+
[:head, :table, :para, :pre, :list, :quote].each do|tag|
|
|
23
|
+
res ||= CasetDown::Check.send tag, cd
|
|
24
|
+
end
|
|
25
|
+
epool += res.instance_of?(Array) ? res : [res]
|
|
26
|
+
end.compact
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 解析标题节点(h1 ~ h6)。
|
|
30
|
+
#
|
|
31
|
+
# 支持内联链接元素(<a> 标签)的提取。
|
|
32
|
+
#
|
|
33
|
+
# @param cd [Node] 标题 DOM 节点
|
|
34
|
+
# @return [Hash, nil] 解析结果
|
|
35
|
+
# - :name [String] 标签名 'h1' ~ 'h6'
|
|
36
|
+
# - :text [String] 标题文本
|
|
37
|
+
# - :inline [Array<Hash>] 内联链接元素(可选)
|
|
38
|
+
# @return [nil] 如果节点不是标题类型
|
|
39
|
+
def head cd
|
|
40
|
+
if %w{h1 h2 h3 h4 h5 h6}.include? cd.name
|
|
41
|
+
unless cd.elements.empty?
|
|
42
|
+
ha = {name: cd.name, text: cd.attributes[:text], inline: [] }
|
|
43
|
+
cd.elements.each{|sub|ha[:inline]<<(sub.name=='a' ? a(sub) : {})}
|
|
44
|
+
ha
|
|
45
|
+
else
|
|
46
|
+
{name: cd.name, text: cd.attributes[:text] }
|
|
47
|
+
end
|
|
48
|
+
else
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 解析表格节点。
|
|
54
|
+
#
|
|
55
|
+
# 将表格转换为 head(表头)和 body(数据行)结构。
|
|
56
|
+
#
|
|
57
|
+
# @param cd [Node] 表格 DOM 节点
|
|
58
|
+
# @return [Hash, nil] 解析结果
|
|
59
|
+
# - :name [String] 'table'
|
|
60
|
+
# - :head [Array<String>] 表头列名数组
|
|
61
|
+
# - :body [Array<Array<String>>] 数据行二维数组
|
|
62
|
+
# @return [nil] 如果节点不是表格类型
|
|
63
|
+
def table cd
|
|
64
|
+
if cd.name=='table'
|
|
65
|
+
hd = cd.to_doc['table'][1]['thead'][1]['tr'][1..-1].map{|th|th['th'][0][:text].strip}
|
|
66
|
+
bd = cd.to_doc['table'][2]['tbody'][1..-1].map{|tr|tr['tr'][1..-1].map{|td|td['td'][0][:text].strip}}
|
|
67
|
+
{name: 'table', head: hd, body: bd}
|
|
68
|
+
else
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# 解析段落节点,支持内联代码、图片、链接元素。
|
|
74
|
+
#
|
|
75
|
+
# 段落文本存储在 attributes[:text] 中,内联元素通过子节点遍历提取。
|
|
76
|
+
#
|
|
77
|
+
# @param cd [Node] 段落 DOM 节点
|
|
78
|
+
# @return [Array<Hash>, nil] 段落内容及其内联元素数组
|
|
79
|
+
# - 首元素通常为 {name: 'p', text: String}
|
|
80
|
+
# - 后续元素为内联的 code, img, a 等
|
|
81
|
+
# @return [nil] 如果节点不是段落类型
|
|
82
|
+
def para cd
|
|
83
|
+
if cd.name=='p'
|
|
84
|
+
cur = []
|
|
85
|
+
cur << (cd.attributes[:text] ? {name: 'p', text: cd.attributes[:text]||cd.attributes["text"]} : nil)
|
|
86
|
+
cd.elements.each do|cs|
|
|
87
|
+
cur << code(cs)
|
|
88
|
+
cur << img(cs)
|
|
89
|
+
cur << a(cs)
|
|
90
|
+
end
|
|
91
|
+
return cur
|
|
92
|
+
else
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# 解析图片节点。
|
|
98
|
+
#
|
|
99
|
+
# @param cd [Node] 图片 DOM 节点
|
|
100
|
+
# @return [Hash, nil] 解析结果
|
|
101
|
+
# - :name [String] 'img'
|
|
102
|
+
# - :src [String] 图片 URL
|
|
103
|
+
# - :alt [String] 替代文本
|
|
104
|
+
# @return [nil] 如果节点不是图片类型
|
|
105
|
+
def img cd
|
|
106
|
+
if cd.name=='img'
|
|
107
|
+
{
|
|
108
|
+
name: 'img',
|
|
109
|
+
src: (cd.attributes['src'] || cd.attributes[:src]),
|
|
110
|
+
alt: (cd.attributes['alt'] || cd.attributes[:alt])
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# 解析链接节点。
|
|
118
|
+
#
|
|
119
|
+
# @param cd [Node] 链接 DOM 节点
|
|
120
|
+
# @return [Hash, nil] 解析结果
|
|
121
|
+
# - :name [String] 'a'
|
|
122
|
+
# - :href [String] 链接目标 URL
|
|
123
|
+
# - :text [String] 链接显示文本
|
|
124
|
+
# @return [nil] 如果节点不是链接类型
|
|
125
|
+
def a cd
|
|
126
|
+
if cd.name=='a'
|
|
127
|
+
{
|
|
128
|
+
name: 'a',
|
|
129
|
+
href: (cd.attributes['href'] || cd.attributes[:href]),
|
|
130
|
+
text: cd.attributes[:text]
|
|
131
|
+
}
|
|
132
|
+
else
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# 解析代码块容器 <pre> 节点。
|
|
138
|
+
#
|
|
139
|
+
# 实际调用 code 方法处理内部 <code> 子节点。
|
|
140
|
+
#
|
|
141
|
+
# @param cd [Node] <pre> DOM 节点
|
|
142
|
+
# @return [Hash, nil] code 方法的返回结果
|
|
143
|
+
# @return [nil] 如果节点不是 <pre> 类型
|
|
144
|
+
def pre cd
|
|
145
|
+
if cd.name=='pre'
|
|
146
|
+
return {
|
|
147
|
+
name: 'pre',
|
|
148
|
+
text: cd.to_doc['pre'][0][:text]
|
|
149
|
+
} if cd.to_doc['pre'][0][:text]
|
|
150
|
+
return code(cd.elements.first) if cd.elements.first.name=='code'
|
|
151
|
+
else
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# 解析内联/块级代码节点。
|
|
157
|
+
#
|
|
158
|
+
# @param cd [Node] <code> DOM 节点
|
|
159
|
+
# @return [Hash, nil] 解析结果
|
|
160
|
+
# - :name [String] 'code'
|
|
161
|
+
# - :class [String, Symbol] 代码语言类,默认 :default
|
|
162
|
+
# - :code [String] 代码文本内容
|
|
163
|
+
# @return [nil] 如果节点不是 <code> 类型
|
|
164
|
+
def code cd
|
|
165
|
+
if cd.name=='code'
|
|
166
|
+
{
|
|
167
|
+
name: 'code',
|
|
168
|
+
class: (
|
|
169
|
+
cd.attributes['class'] ? cd.attributes['class'] :
|
|
170
|
+
cd.attributes[:class] ? cd.attributes[:class] :
|
|
171
|
+
:default
|
|
172
|
+
),
|
|
173
|
+
code: cd.attributes[:text].strip
|
|
174
|
+
}
|
|
175
|
+
else
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# 解析无序列表节点 <ul> 和 有序列表节点 <ol>。
|
|
181
|
+
#
|
|
182
|
+
# 注意:当前仅支持顶层列表,子列表递归处理待实现。
|
|
183
|
+
# 列表项支持内联的 blockquote、code、img、a 等元素。
|
|
184
|
+
#
|
|
185
|
+
# @param cd [Node] <ul> <ol> DOM 节点
|
|
186
|
+
# @return [Hash, nil] 解析结果
|
|
187
|
+
# - :name [String] 'ul' 'ol'
|
|
188
|
+
# - :list [Array<Hash>] 列表项数组,每项包含 :name, :text, :inline
|
|
189
|
+
# @return [nil] 如果节点不是 <ul> <ol> 类型
|
|
190
|
+
def list cd
|
|
191
|
+
# TODO: ONLY TOP LEVEL, PENDING TO RECURSION
|
|
192
|
+
if cd.name=='ul'
|
|
193
|
+
subs = cd.elements.inject([]) do|subs, li|
|
|
194
|
+
sub = {name: 'li', text: (li.attributes[:text] ? li.attributes[:text].strip : li.attributes['text'] ? li.attributes['text'].strip : nil)}
|
|
195
|
+
li.elements.each do|subsub|
|
|
196
|
+
sub.merge!(inline: quote(subsub)) if subsub.name=='blockquote'
|
|
197
|
+
sub.merge!(inline: code(subsub)) if subsub.name=='code'
|
|
198
|
+
sub.merge!(inline: pre(subsub)) if subsub.name=='pre'
|
|
199
|
+
sub.merge!(inline: img(subsub)) if subsub.name=='img'
|
|
200
|
+
sub.merge!(inline: a(subsub)) if subsub.name=='a'
|
|
201
|
+
end
|
|
202
|
+
subs << sub
|
|
203
|
+
end
|
|
204
|
+
{name: 'ul', list: subs}
|
|
205
|
+
elsif cd.name=='ol'
|
|
206
|
+
subs = cd.elements.inject([]) do|subs, li|
|
|
207
|
+
sub = {name: 'li', text: (li.attributes[:text] ? li.attributes[:text].strip : li.attributes['text'] ? li.attributes['text'].strip : nil)}
|
|
208
|
+
li.elements.each do|subsub|
|
|
209
|
+
sub.merge!(inline: quote(subsub)) if subsub.name=='blockquote'
|
|
210
|
+
sub.merge!(inline: code(subsub)) if subsub.name=='code'
|
|
211
|
+
sub.merge!(inline: pre(subsub)) if subsub.name=='pre'
|
|
212
|
+
sub.merge!(inline: img(subsub)) if subsub.name=='img'
|
|
213
|
+
sub.merge!(inline: a(subsub)) if subsub.name=='a'
|
|
214
|
+
end
|
|
215
|
+
subs << sub
|
|
216
|
+
end
|
|
217
|
+
{name: 'ol', list: subs}
|
|
218
|
+
else
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# 解析引用块 <blockquote>。
|
|
224
|
+
#
|
|
225
|
+
# 注意:当前仅支持顶层引用,嵌套处理待实现。
|
|
226
|
+
#
|
|
227
|
+
# @param cd [Node] <blockquote> DOM 节点
|
|
228
|
+
# @return [Hash, nil] 解析结果
|
|
229
|
+
# - :name [String] 'blockquote'
|
|
230
|
+
# - :quote [String] 引用文本内容
|
|
231
|
+
# @return [nil] 如果节点不是 <blockquote> 类型
|
|
232
|
+
def quote cd
|
|
233
|
+
# TODO: ONLY TOP LEVEL, PENDING TO RECURSION
|
|
234
|
+
if cd.name=='blockquote'
|
|
235
|
+
{name: 'blockquote', quote: cd.to_doc['blockquote'][0][:text].to_s.strip}
|
|
236
|
+
else
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|