przn 0.1.5

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,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strscan'
4
+
5
+ module Przn
6
+ module Parser
7
+ # Size names → Kitty text sizing scale (1-7)
8
+ SIZE_SCALES = {
9
+ 'xx-small' => 1,
10
+ 'x-small' => 1,
11
+ 'small' => 2,
12
+ 'large' => 3,
13
+ 'x-large' => 4,
14
+ 'xx-large' => 5,
15
+ 'xxx-large' => 6,
16
+ 'xxxx-large' => 7,
17
+ '1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7,
18
+ }.freeze
19
+
20
+ NAMED_COLORS = {
21
+ 'red' => 31, 'green' => 32, 'yellow' => 33, 'blue' => 34,
22
+ 'magenta' => 35, 'cyan' => 36, 'white' => 37,
23
+ 'bright_red' => 91, 'bright_green' => 92, 'bright_yellow' => 93,
24
+ 'bright_blue' => 94, 'bright_magenta' => 95, 'bright_cyan' => 96,
25
+ 'bright_white' => 97,
26
+ }.freeze
27
+
28
+ module_function
29
+
30
+ def parse(markdown)
31
+ slides = split_slides(markdown)
32
+ Presentation.new(slides.map { |raw| parse_slide(raw) })
33
+ end
34
+
35
+ # Split on h1 headings (Rabbit-compatible)
36
+ def split_slides(markdown)
37
+ chunks = []
38
+ current = +""
39
+ in_fence = false
40
+
41
+ markdown.each_line do |line|
42
+ if line.match?(/\A\s*```/)
43
+ in_fence = !in_fence
44
+ current << line
45
+ elsif !in_fence && line.match?(/\A#\s/)
46
+ chunks << current unless current.strip.empty?
47
+ current = +line
48
+ else
49
+ current << line
50
+ end
51
+ end
52
+ chunks << current unless current.strip.empty?
53
+ chunks
54
+ end
55
+
56
+ def parse_slide(raw)
57
+ blocks = []
58
+ lines = raw.lines
59
+ i = 0
60
+
61
+ while i < lines.size
62
+ line = lines[i]
63
+
64
+ case line
65
+ # {::comment} ... {:/comment} block — skip entirely
66
+ when /\A\s*\{::comment\}/
67
+ i += 1
68
+ i += 1 while i < lines.size && !lines[i].match?(/\{:\/comment\}/)
69
+
70
+ # Block alignment: {:.center} or {:.right}
71
+ when /\A\s*\{:\.(\w+)\}\s*\z/
72
+ blocks << {type: :align, align: Regexp.last_match(1).to_sym}
73
+
74
+ # Fenced code block
75
+ when /\A\s*```(\w*)\s*\z/
76
+ lang = Regexp.last_match(1)
77
+ lang = nil if lang.empty?
78
+ code_lines = []
79
+ i += 1
80
+ while i < lines.size && !lines[i].match?(/\A\s*```\s*\z/)
81
+ code_lines << lines[i]
82
+ i += 1
83
+ end
84
+ # Check for kramdown IAL on next line: {: lang="ruby"}
85
+ if (i + 1) < lines.size && lines[i + 1]&.match?(/\A\s*\{:/)
86
+ i += 1
87
+ if lines[i].match(/lang="(\w+)"/)
88
+ lang = Regexp.last_match(1)
89
+ end
90
+ end
91
+ blocks << {type: :code_block, content: code_lines.join, language: lang}
92
+
93
+ # Indented code block (4 spaces)
94
+ when /\A {4}(.*)$/
95
+ code_lines = [Regexp.last_match(1)]
96
+ while (i + 1) < lines.size && lines[i + 1].match?(/\A {4}/)
97
+ i += 1
98
+ code_lines << lines[i].sub(/\A {4}/, '')
99
+ end
100
+ # Check for kramdown IAL: {: lang="ruby"}
101
+ lang = nil
102
+ if (i + 1) < lines.size && lines[i + 1]&.match?(/\A\s*\{:/)
103
+ i += 1
104
+ if lines[i].match(/lang="(\w+)"/)
105
+ lang = Regexp.last_match(1)
106
+ end
107
+ end
108
+ blocks << {type: :code_block, content: code_lines.join("\n") + "\n", language: lang}
109
+
110
+ # h1 (slide title)
111
+ when /\A#\s+(.*)/
112
+ blocks << {type: :heading, level: 1, content: Regexp.last_match(1).strip}
113
+
114
+ # h2-h6 (sub-headings within slide)
115
+ when /\A(\#{2,6})\s+(.*)/
116
+ level = Regexp.last_match(1).size
117
+ text = Regexp.last_match(2).strip
118
+ blocks << {type: :heading, level: level, content: text}
119
+
120
+ # Block quote
121
+ when /\A>\s?(.*)/
122
+ quote_lines = [Regexp.last_match(1)]
123
+ while (i + 1) < lines.size && (m = lines[i + 1].match(/\A>\s?(.*)/))
124
+ i += 1
125
+ quote_lines << m[1]
126
+ end
127
+ blocks << {type: :blockquote, content: quote_lines.join("\n")}
128
+
129
+ # Table
130
+ when /\A\|/
131
+ table_lines = [line.strip]
132
+ while (i + 1) < lines.size && lines[i + 1].match?(/\A\|/)
133
+ i += 1
134
+ table_lines << lines[i].strip
135
+ end
136
+ blocks << parse_table(table_lines)
137
+
138
+ # Unordered list (* or - item)
139
+ when /\A[*\-]\s+(.*)/
140
+ items = []
141
+ while i < lines.size && (lines[i].match?(/\A[*\-]\s+/) || lines[i].match?(/\A {2,}[*\-]\s+/) || lines[i].match?(/\A {2,}\S/))
142
+ if lines[i].match(/\A(\s*)[*\-]\s+(.*)/)
143
+ depth = Regexp.last_match(1).size / 2
144
+ items << {text: Regexp.last_match(2), depth: depth}
145
+ elsif lines[i].match(/\A {2,}(\S.*)/)
146
+ # Continuation line
147
+ items.last[:text] << " " << Regexp.last_match(1) if items.last
148
+ else
149
+ break
150
+ end
151
+ i += 1
152
+ end
153
+ i -= 1
154
+ blocks << {type: :unordered_list, items: items}
155
+
156
+ # Ordered list
157
+ when /\A(\s*)\d+\.\s+(.*)/
158
+ items = []
159
+ while i < lines.size && lines[i].match?(/\A\s*\d+\.\s+/)
160
+ lines[i].match(/\A(\s*)\d+\.\s+(.*)/)
161
+ depth = Regexp.last_match(1).size / 3
162
+ items << {text: Regexp.last_match(2), depth: depth}
163
+ i += 1
164
+ end
165
+ i -= 1
166
+ blocks << {type: :ordered_list, items: items}
167
+
168
+ # Image: ![alt](path "title"){:attrs}
169
+ when /\A!\[([^\]]*)\]\((\S+?)(?:\s+"([^"]*)")?\)(.*)/
170
+ alt = Regexp.last_match(1)
171
+ path = Regexp.last_match(2)
172
+ title = Regexp.last_match(3)
173
+ rest = Regexp.last_match(4).strip
174
+ attrs = {}
175
+ if rest.match(/\{([^}]+)\}/)
176
+ parse_image_attrs(Regexp.last_match(1), attrs)
177
+ elsif rest.match(/\{(.+)/) || ((i + 1) < lines.size && lines[i + 1]&.match?(/\A\s*\{/))
178
+ attr_str = rest.sub(/\A\{:?\s*/, '')
179
+ while !attr_str.include?('}') && (i + 1) < lines.size
180
+ i += 1
181
+ attr_str << " " << lines[i].strip
182
+ end
183
+ attr_str = attr_str.sub(/\}\s*\z/, '')
184
+ parse_image_attrs(attr_str, attrs)
185
+ end
186
+ blocks << {type: :image, path: path, alt: alt, title: title, attrs: attrs}
187
+
188
+ # Definition list: term on one line, : definition on next
189
+ when /\A(\S.*)\s*\z/
190
+ if (i + 1) < lines.size && lines[i + 1].match?(/\A:\s{3}/)
191
+ term = Regexp.last_match(1).strip
192
+ i += 1
193
+ definition_lines = []
194
+ while i < lines.size && lines[i].match?(/\A:\s{3}(.*)|\A {4}(.*)/)
195
+ if lines[i].match(/\A:\s{3}(.*)/)
196
+ definition_lines << Regexp.last_match(1)
197
+ elsif lines[i].match(/\A {4}(.*)/)
198
+ definition_lines << Regexp.last_match(1)
199
+ end
200
+ i += 1
201
+ end
202
+ i -= 1
203
+ blocks << {type: :definition_list, term: term, definition: definition_lines.join("\n")}
204
+ else
205
+ blocks << {type: :paragraph, content: Regexp.last_match(1).strip}
206
+ end
207
+
208
+ when /\A\s*\z/
209
+ blocks << {type: :blank}
210
+
211
+ else
212
+ blocks << {type: :paragraph, content: line.strip}
213
+ end
214
+ i += 1
215
+ end
216
+
217
+ Slide.new(blocks)
218
+ end
219
+
220
+ def parse_image_attrs(str, attrs)
221
+ str = str.sub(/\A:?\s*/, '')
222
+ str.scan(/([\w-]+)=['"]([^'"]*)['"]/) do |key, value|
223
+ attrs[key.tr('-', '_')] = value
224
+ end
225
+ end
226
+
227
+ def parse_table(lines)
228
+ rows = []
229
+ lines.each do |line|
230
+ next if line.match?(/\A\|[-|:\s]+\|\s*\z/) # separator row
231
+
232
+ cells = line.split('|').map(&:strip).reject(&:empty?)
233
+ rows << cells
234
+ end
235
+ {type: :table, header: rows.first, rows: rows.drop(1)}
236
+ end
237
+
238
+ # Parse inline text with Rabbit-compatible markup
239
+ def parse_inline(text)
240
+ segments = []
241
+ scanner = StringScanner.new(text)
242
+
243
+ until scanner.eos?
244
+ if scanner.scan(/\{::tag\s+name="([^"]+)"\}(.*?)\{:\/tag\}/)
245
+ segments << [:tag, scanner[2], scanner[1]]
246
+ elsif scanner.scan(/\{::note\}(.*?)\{:\/note\}/)
247
+ segments << [:note, scanner[1]]
248
+ elsif scanner.scan(/\{::wait\/\}/)
249
+ # skip wait markers in inline text
250
+ elsif scanner.scan(/`([^`]+)`/)
251
+ segments << [:code, scanner[1]]
252
+ elsif scanner.scan(/\*\*(.+?)\*\*/)
253
+ segments << [:bold, scanner[1]]
254
+ elsif scanner.scan(/\*(.+?)\*/)
255
+ segments << [:italic, scanner[1]]
256
+ elsif scanner.scan(/~~(.+?)~~/)
257
+ segments << [:strikethrough, scanner[1]]
258
+ else
259
+ segments << [:text, scanner.scan(/[^`*~{]+|./)]
260
+ end
261
+ end
262
+
263
+ segments
264
+ end
265
+ end
266
+ end