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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/LICENSE.txt +21 -0
- data/README.md +285 -0
- data/USAGE.md +789 -0
- data/lib/mdom/block_detector.rb +200 -0
- data/lib/mdom/builder.rb +378 -163
- data/lib/mdom/error.rb +22 -0
- data/lib/mdom/hooks.rb +81 -0
- data/lib/mdom/html_serializer.rb +330 -0
- data/lib/mdom/inline_parser.rb +541 -142
- data/lib/mdom/markdown_serializer.rb +332 -0
- data/lib/mdom/node/inline_nodes.rb +239 -0
- data/lib/mdom/node/nodes.rb +336 -0
- data/lib/mdom/node.rb +677 -199
- data/lib/mdom/parser.rb +257 -235
- data/lib/mdom/tokenizer.rb +415 -0
- data/lib/mdom/translator.rb +379 -0
- data/lib/mdom/version.rb +1 -1
- data/lib/mdom.rb +58 -7
- metadata +40 -7
- data/lib/mdom/nodes.rb +0 -214
- data/lib/mdom/serializer.rb +0 -165
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 = { "&" => "&", "<" => "<", ">" => ">" }.freeze
|
|
28
|
+
|
|
29
|
+
# 属性值的转义表(多一个引号)。
|
|
30
|
+
ATTRIBUTE_ESCAPES = { "&" => "&", "<" => "<", ">" => ">", "\"" => """ }.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
|