mdom 0.2.0 → 0.3.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.
data/lib/mdom/error.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MDOM
4
+ # CSM 处理的错误基类。
5
+ class Error < StandardError; end
6
+
7
+ # CSM 源码不合法(解析期)。消息中带文档行号,便于定位。
8
+ class ParseError < Error; end
9
+
10
+ # 判定为 Ruby 块,但块首行与块尾行的 CSM 缩进不同(CSM §3.5.2 条件 1)。
11
+ class CrossLevelError < ParseError; end
12
+
13
+ # 围栏代码块没有找到对称的结束围栏(扫描到文档末尾)。
14
+ class UnclosedFenceError < ParseError; end
15
+
16
+ # 块级公式的 `$$` 包围式没有找到独占一行的结束标记(扫描到文档末尾)。
17
+ class UnclosedMathError < ParseError; end
18
+
19
+ # 块里出现 heredoc。CSM §3.12 明确**不支持** heredoc(附录 E),一旦出现就报错,
20
+ # 而不是把它当普通块收下。
21
+ class UnsupportedHeredocError < ParseError; end
22
+ end
data/lib/mdom/hooks.rb ADDED
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MDOM
4
+ # 钩子 / 回调系统(CSM §5.3)。
5
+ #
6
+ # 一次 `MDOM.compile` 依次经过五个点,注册的回调按**注册顺序**执行:
7
+ #
8
+ # | 点 | 时机 | 载荷 |
9
+ # |----|------|------|
10
+ # | `:after_parse` | (1) 完成、行内已解析 | `doc` |
11
+ # | `:before_compile` | (2) 翻译之前——树仍可改写 | `doc` |
12
+ # | `:after_compile` | (2) 完成 | `doc, ruby` |
13
+ # | `:before_serialize` | (6) 之前 | `doc` |
14
+ # | `:after_serialize` | (6) 之后 | `html, meta` |
15
+ #
16
+ # **返回值被忽略**:载荷里的可变对象(节点树、产物字符串)由回调直接改写——这与
17
+ # CSM §5.3 例子里 `html.insert(...)` 是同一风格。
18
+ #
19
+ # 两种作用域:类级注册表(`Document.register_hook`)对所有文档生效;
20
+ # `MDOM.compile(source, hooks: { before_compile: [proc] })` 传入的**临时**钩子只对
21
+ # 那一次调用生效。执行顺序是「全局 → 临时」。
22
+ #
23
+ # 属 **builder 侧**(docs/IMPLEMENTATION.md §1.1):只用 Hash / Array / 闭包,
24
+ # 零 gem 依赖,可在 mruby/WASM 上跑。
25
+ module Hooks
26
+ # 五个触发点。
27
+ POINTS = %i[
28
+ after_parse before_compile after_compile before_serialize after_serialize
29
+ ].freeze
30
+
31
+ class << self
32
+ # 在 +point+ 上注册一个回调(块),返回该回调。
33
+ def register(point, &block)
34
+ assert_point!(point)
35
+ raise ArgumentError, "a hook needs a block" if block.nil?
36
+
37
+ table(point) << block
38
+ block
39
+ end
40
+
41
+ # 取消 +point+ 上的某个回调;没注册过时返回 +nil+。
42
+ def unregister(point, block)
43
+ assert_point!(point)
44
+ table(point).delete(block)
45
+ end
46
+
47
+ # 清空 +point+ 上的回调;不给点时清空全部。
48
+ def clear(point = nil)
49
+ point.nil? ? @table = {} : table(point).clear
50
+ self
51
+ end
52
+
53
+ # +point+ 上已注册的回调(副本,改动它不影响注册表)。
54
+ def registered(point)
55
+ assert_point!(point)
56
+ table(point).dup
57
+ end
58
+
59
+ # 依次运行 +point+ 上的回调:先全局(注册顺序),再 +extra+(本次调用传入的
60
+ # 临时钩子)。返回值被忽略;回调抛出的异常**直接传播**。
61
+ def run(point, *args, extra: [])
62
+ assert_point!(point)
63
+ (table(point) + extra).each { |hook| hook.call(*args) }
64
+ nil
65
+ end
66
+
67
+ private
68
+
69
+ def table(point = nil)
70
+ @table ||= {}
71
+ point.nil? ? @table : (@table[point] ||= [])
72
+ end
73
+
74
+ def assert_point!(point)
75
+ return if POINTS.include?(point)
76
+
77
+ raise ArgumentError, "unknown hook point #{point.inspect} (expected one of #{POINTS.inspect})"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "node/inline_nodes"
5
+ require_relative "node/nodes"
6
+
7
+ module MDOM
8
+ # (6) AST → HTML(CSM §1/§2/§7;docs/IMPLEMENTATION.md §7)。
9
+ #
10
+ # 输入可以是 **AST1 或 AST2**:两者是同一棵树类型,区别只在是否还残留未求值的
11
+ # 嵌入节点。未求值的嵌入(`RubyEmbed` / 行内 `Interpolation`)按**独立的嵌入
12
+ # 样式**输出——结构与相应的代码块同构,另加 `csm-ruby` class。因此「安全渲染」
13
+ # 与「不安全渲染」的差别只在于 (4) 是否执行嵌入代码,而不是两条序列化路径
14
+ # (CSM §3.14)。
15
+ #
16
+ # MDOM::HtmlSerializer.serialize(MDOM::Parser.parse("正文 **粗**\n"))
17
+ # # => "<p>正文 <strong>粗</strong></p>"
18
+ #
19
+ # == 依赖边界
20
+ #
21
+ # 本文件属 **builder 侧**(docs/IMPLEMENTATION.md §1.1):零 gem 依赖、不
22
+ # `require` prism、不使用正则字面量(`Regexp` 在 mruby 上需外部 gem,§5.5.3),
23
+ # 因此 mruby / WASM 也能跑。这也是它只消费 AST、**从不解析字符串**的原因——
24
+ # 例如引用的 `cite` 只有原文,行内不在这里解析。
25
+ class HtmlSerializer
26
+ # 文本节点的转义表。
27
+ TEXT_ESCAPES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;" }.freeze
28
+
29
+ # 属性值的转义表(多一个引号)。
30
+ ATTRIBUTE_ESCAPES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", "\"" => "&quot;" }.freeze
31
+
32
+ # 块级公式的容器 class(CSM §1.11)。
33
+ MATH_CLASS = "math"
34
+
35
+ # 未求值嵌入的容器 class。
36
+ EMBED_CLASS = "csm-ruby"
37
+
38
+ def self.serialize(node, **options)
39
+ new(**options).serialize(node)
40
+ end
41
+
42
+ # +known_targets+ 是 WikiLink 的目标集合(可迭代,元素按 #slug 归一并忽略大小写);给定时
43
+ # 不在其中的链接会带上 +orphan-link+ class(CSM §2.1),省略时不作判定。
44
+ def initialize(known_targets: nil)
45
+ @known_targets = known_targets && known_targets.map { |target| [slug(target), true] }.to_h
46
+ end
47
+
48
+ # 序列化一棵树(+Node::Document+ 或任意子节点),返回 HTML 字符串。
49
+ def serialize(node)
50
+ return document(node) if node.type == :document
51
+
52
+ block(node)
53
+ end
54
+
55
+ private
56
+
57
+ # -- 文档 -----------------------------------------------------------------
58
+
59
+ # 一级标题会把自身和文档内容包进 `<article>`(CSM §1.2)。
60
+ def document(node)
61
+ children = visible_children(node)
62
+ body = sections(children)
63
+
64
+ return body unless children.any? { |child| heading?(child, 1) }
65
+
66
+ element("article", body)
67
+ end
68
+
69
+ # 二级标题会把自身到下一个二级标题(或文末)之前的内容包进 `<section>`
70
+ # (CSM §1.2)。h3 及以下不再包裹。
71
+ def sections(children)
72
+ groups = [[]]
73
+ children.each do |child|
74
+ groups << [] if heading?(child, 2)
75
+ groups.last << child
76
+ end
77
+
78
+ groups.reject(&:empty?).map do |group|
79
+ body = block_statements(group)
80
+ heading?(group.first, 2) ? element("section", body) : body
81
+ end.join("\n")
82
+ end
83
+
84
+ # 依次输出块级子节点。标题带收尾 `#` 时,紧跟着的段落与它同处一个 `<hgroup>`
85
+ # (CSM §1.2)。
86
+ def block_statements(children)
87
+ html = []
88
+ index = 0
89
+ while index < children.size
90
+ child = children[index]
91
+ follower = children[index + 1]
92
+ if hgroup?(child, follower)
93
+ html << element("hgroup", [block(child), block(follower)].join("\n"))
94
+ index += 2
95
+ else
96
+ html << block(child)
97
+ index += 1
98
+ end
99
+ end
100
+ html.reject(&:empty?).join("\n")
101
+ end
102
+
103
+ def hgroup?(heading, follower)
104
+ heading.type == :heading && heading.closed? &&
105
+ !follower.nil? && follower.type == :paragraph
106
+ end
107
+
108
+ def visible_children(node)
109
+ node.children.reject { |child| child.type == :end_marker }
110
+ end
111
+
112
+ def heading?(node, level)
113
+ node.type == :heading && node.level == level
114
+ end
115
+
116
+ # -- 块级 -----------------------------------------------------------------
117
+
118
+ def block(node)
119
+ case node.type
120
+ when :div then element("div", children_html(node))
121
+ when :paragraph then tag("p", inline_children(node))
122
+ when :heading then heading_html(node)
123
+ when :blockquote then blockquote_html(node)
124
+ when :code_block then code_block_html(node)
125
+ when :math_block then tag("div", escape(node.tex, TEXT_ESCAPES), class: MATH_CLASS)
126
+ when :table then element("table", children_html(node))
127
+ when :table_row then element("tr", children_html(node))
128
+ when :table_header_cell then cell_html(node, "th")
129
+ when :table_cell then cell_html(node, "td")
130
+ when :list then list_html(node)
131
+ when :list_item then list_item_html(node)
132
+ when :definition_list then element("dl", children_html(node))
133
+ when :term then tag("dt", inline_children(node))
134
+ when :cite then tag("cite", inline_children(node))
135
+ when :definition then element("dd", children_html(node))
136
+ when :hrule then "<hr />"
137
+ when :ruby_embed then ruby_embed_html(node)
138
+ when :end_marker then ""
139
+ else raise Error, "cannot serialize #{node.type.inspect} to HTML"
140
+ end
141
+ end
142
+
143
+ def children_html(node)
144
+ block_statements(node.children)
145
+ end
146
+
147
+ # ATX 标题:`<h1>`–`<h6>` 带 +id+ 锚点(CSM §1.2)。
148
+ def heading_html(node)
149
+ attrs = { id: slug(plain_text(node)) }
150
+ tag("h#{node.level}", inline_children(node), attrs)
151
+ end
152
+
153
+ # 引用:`>-` 的出处是普通的 +Node::Cite+ 子节点(CSM §1.4)。
154
+ def blockquote_html(node)
155
+ element("blockquote", children_html(node))
156
+ end
157
+
158
+ def code_block_html(node)
159
+ tag("pre", tag("code", escape(node.code, TEXT_ESCAPES), code_attributes(node)))
160
+ end
161
+
162
+ # +lang+ → `class="language-<lang>"`;+params+ 原样放进 +data-params+。
163
+ def code_attributes(node)
164
+ attrs = {}
165
+ attrs[:class] = "language-#{node.lang}" if node.lang
166
+ attrs[:params] = node.params if node.params
167
+ attrs
168
+ end
169
+
170
+ # 未求值的嵌入块:与围栏代码块同构,另加 #EMBED_CLASS。
171
+ def ruby_embed_html(node)
172
+ tag("pre", tag("code", escape(node.source, TEXT_ESCAPES), class: EMBED_CLASS))
173
+ end
174
+
175
+ def cell_html(node, name)
176
+ attrs = {}
177
+ attrs[:align] = node.align if node.align
178
+ tag(name, inline_children(node), attrs)
179
+ end
180
+
181
+ def list_html(node)
182
+ attrs = {}
183
+ attrs[:task] = "true" if node.task?
184
+ element(node.ordered? ? "ol" : "ul", children_html(node), attrs)
185
+ end
186
+
187
+ # 任务项的状态由 `<input type="checkbox">` 承载(CSM §1.3)。
188
+ def list_item_html(node)
189
+ body = [checkbox_html(node), children_html(node)].reject(&:empty?).join("\n")
190
+ element("li", body)
191
+ end
192
+
193
+ def checkbox_html(node)
194
+ return "" unless node.task?
195
+
196
+ attrs = { type: "checkbox" }
197
+ attrs[:checked] = "checked" if node.checked
198
+ tag("input", nil, attrs)
199
+ end
200
+
201
+ # -- 行内 -----------------------------------------------------------------
202
+
203
+ def inline_children(node)
204
+ node.children.map { |child| inline(child) }.join
205
+ end
206
+
207
+ def inline(node)
208
+ case node.type
209
+ when :text then escape(node.text, TEXT_ESCAPES)
210
+ when :emphasis then tag("em", inline_children(node))
211
+ when :strong then tag("strong", inline_children(node))
212
+ when :code_span then tag("code", escape(node.code, TEXT_ESCAPES))
213
+ when :link then tag("a", inline_children(node), href: node.uri, title: node.title)
214
+ when :inline_quote then tag("q", inline_children(node), cite: node.uri)
215
+ when :image then tag("img", nil, src: node.uri, alt: node.alt, title: node.title)
216
+ when :wiki_link then wiki_link_html(node)
217
+ when :mention then mention_html(node)
218
+ when :del then tag("del", inline_children(node))
219
+ when :ins then tag("ins", inline_children(node))
220
+ when :hard_break then "<br />"
221
+ when :meter then tag("meter", "", value: node.value, max: node.max)
222
+ when :progress then tag("progress", "", value: node.value, max: node.max)
223
+ when :math then tag("span", escape(node.tex, TEXT_ESCAPES), class: MATH_CLASS)
224
+ when :interpolation then tag("code", escape(node.expr, TEXT_ESCAPES), class: EMBED_CLASS)
225
+ else raise Error, "cannot serialize inline #{node.type.inspect} to HTML"
226
+ end
227
+ end
228
+
229
+ # WikiLink → `<a>`;目标不在 +known_targets+ 里时带 `orphan-link`(CSM §2.1)。
230
+ def wiki_link_html(node)
231
+ target = node.attributes[:target]
232
+ attrs = { href: slug(target) }
233
+ attrs[:class] = "orphan-link" if orphan?(target)
234
+ tag("a", inline_children(node), attrs)
235
+ end
236
+
237
+ def orphan?(target)
238
+ return false if @known_targets.nil?
239
+
240
+ !@known_targets.key?(slug(target))
241
+ end
242
+
243
+ def mention_html(node)
244
+ scope = node.attributes[:kind]
245
+ label = "@#{scope ? "#{scope}:" : ""}#{node.attributes[:name]}"
246
+ tag("a", escape(label, TEXT_ESCAPES), class: "mention", href: label)
247
+ end
248
+
249
+ # -- 输出原语 -------------------------------------------------------------
250
+
251
+ def element(name, body, attrs = nil)
252
+ opening = "<#{name}#{render_attributes(attrs)}>"
253
+ return "#{opening}</#{name}>" if body.empty?
254
+
255
+ "#{opening}\n#{body}\n</#{name}>"
256
+ end
257
+
258
+ # +body+ 为 +nil+ 时按 void 元素自闭合(`<hr />`)。
259
+ def tag(name, body, attrs = nil)
260
+ opening = "<#{name}#{render_attributes(attrs)}"
261
+ return "#{opening} />" if body.nil?
262
+
263
+ "#{opening}>#{body}</#{name}>"
264
+ end
265
+
266
+ # 紧凑写法:`attrs` 是 +属性名 => 值+ 的散列,+nil+ 值被跳过。少数属性用
267
+ # 「名字」而非属性承载(`task`、`align`),这里各自映射成 HTML 形式。
268
+ def render_attributes(attrs)
269
+ return "" if attrs.nil?
270
+
271
+ attrs.reject { |_name, value| value.nil? }.map do |name, value|
272
+ rendered = escape(html_attribute_value(name, value), ATTRIBUTE_ESCAPES)
273
+ %( #{html_attribute_name(name)}="#{rendered}")
274
+ end.join
275
+ end
276
+
277
+ def html_attribute_name(name)
278
+ case name
279
+ when :align then "style"
280
+ when :task then "class"
281
+ when :params then "data-params"
282
+ else name.to_s
283
+ end
284
+ end
285
+
286
+ def html_attribute_value(name, value)
287
+ case name
288
+ when :align then "text-align: #{value};"
289
+ when :task then "task"
290
+ else value.to_s
291
+ end
292
+ end
293
+
294
+ # -- 转义与文本 -----------------------------------------------------------
295
+
296
+ def escape(text, table)
297
+ result = +""
298
+ text.to_s.each_char { |char| result << table.fetch(char, char) }
299
+ result
300
+ end
301
+
302
+ # 标题锚点 / WikiLink 目标的规范化:去首尾空白,内部的空白连成 `-`
303
+ # (CSM §2.1「空格自动转为 `-`」)。
304
+ # 标题锚点与 WikiLink 目标共用同一套 slug:去空白、把空白连成 `-`,并统一成
305
+ # **小写**(CSM §2.1 要求 WikiLink 目标不区分大小写)。两者一致,`[[笔记#锚点]]`
306
+ # 才能对上标题的 `id`。
307
+ def slug(text)
308
+ text.to_s.split.join("-").downcase
309
+ end
310
+
311
+ # 行内子树的纯文本(用于标题锚点):字符串不在这里解析,只取已有内容。
312
+ def plain_text(node)
313
+ return node.text.to_s if node.type == :text
314
+ return node.code.to_s if node.type == :code_span
315
+
316
+ node.children.map { |child| plain_text(child) }.join
317
+ end
318
+ end
319
+ end
320
+
321
+ module MDOM
322
+ module Node
323
+ # 便捷入口:+node.to_html+ ≡ +MDOM::HtmlSerializer.serialize(node)+。
324
+ class BaseNode
325
+ def to_html
326
+ HtmlSerializer.serialize(self)
327
+ end
328
+ end
329
+ end
330
+ end