teepee 0.6.0 → 0.7.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/lib/teepee.rb +17 -529
- data/lib/teepee/actionable-commander.rb +89 -0
- data/lib/teepee/command-parser.rb +268 -0
- data/lib/teepee/commander.rb +320 -0
- data/lib/teepee/constants.rb +40 -0
- data/lib/teepee/errors.rb +41 -0
- data/lib/teepee/number-token.rb +68 -0
- data/lib/teepee/paragraph-parser.rb +80 -0
- data/lib/teepee/parser-node.rb +41 -0
- data/lib/teepee/parser.rb +77 -0
- data/lib/teepee/single-character-token.rb +72 -0
- data/lib/teepee/string-token.rb +123 -0
- data/lib/teepee/token.rb +53 -0
- data/lib/teepee/tokenizer.rb +75 -0
- metadata +15 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7d3f7f012149fdff0a39dfec0d8a8dd301eec2e0
|
4
|
+
data.tar.gz: 75cc86a2801db966891c6be12570b496c21c5f15
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c30fedd28550b28526f5f0b259c44939fe04b0727f8ce65c403430d98788bd17a345c4c37458db4a2779581cadc7c49b8fd008bf57a0b080a4c1031b8da82de0
|
7
|
+
data.tar.gz: 5634379fef912b567b5e55bbedd3a6d623fe49d4d11968bf85522f7bfb34dcff8b91a59896f1e9912d71cd668baeb0f3ec5f8553dced5e71da420fb1901923b4
|
data/lib/teepee.rb
CHANGED
@@ -35,536 +35,24 @@
|
|
35
35
|
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
36
36
|
# POSSIBILITY OF SUCH DAMAGE.
|
37
37
|
|
38
|
-
require 'active_support/all'
|
39
|
-
require 'monkey-patch'
|
40
|
-
|
41
|
-
include ERB::Util
|
42
|
-
|
43
38
|
module Teepee
|
44
|
-
|
45
|
-
|
46
|
-
module MathFunctions
|
47
|
-
class << self
|
48
|
-
def degrees2radians degrees
|
49
|
-
degrees * Math::PI / 180.0
|
50
|
-
end
|
51
|
-
|
52
|
-
def lgamma n
|
53
|
-
Math::lgamma(n).first
|
54
|
-
end
|
55
|
-
|
56
|
-
def radians2degrees radians
|
57
|
-
radians * 180.0 / Math::PI
|
58
|
-
end
|
59
|
-
end
|
60
|
-
end
|
61
|
-
|
62
|
-
class ParseError < RuntimeError
|
63
|
-
end
|
64
|
-
|
65
|
-
class ParserNode
|
66
|
-
end
|
67
|
-
|
68
|
-
class Token < ParserNode
|
69
|
-
class << self
|
70
|
-
# The child classes should implement this method. If there is an
|
71
|
-
# immediate match, they should return a newly-created instance of
|
72
|
-
# themselves and the rest of the input as a string. If there is no match,
|
73
|
-
# they should return nil.
|
74
|
-
def matches? text
|
75
|
-
raise NotImplementedError,
|
76
|
-
"Child class #{self.class} should implement this."
|
77
|
-
end
|
78
|
-
end
|
79
|
-
end
|
80
|
-
|
81
|
-
class SingleCharacterToken < Token
|
82
|
-
def text
|
83
|
-
self.class.character_matched
|
84
|
-
end
|
85
|
-
|
86
|
-
class << self
|
87
|
-
def character_matched
|
88
|
-
self::CHARACTER_MATCHED
|
89
|
-
end
|
90
|
-
|
91
|
-
def matches? text
|
92
|
-
if text.first == character_matched
|
93
|
-
return [self.new, text.rest]
|
94
|
-
else
|
95
|
-
return nil
|
96
|
-
end
|
97
|
-
end
|
98
|
-
end
|
99
|
-
end
|
100
|
-
|
101
|
-
class StringToken < Token
|
102
|
-
attr_reader :text
|
103
|
-
|
104
|
-
def initialize(text)
|
105
|
-
raise ArgumentError if not text.is_a? String
|
106
|
-
raise ArgumentError if not text =~ self.class.full_match_regex
|
107
|
-
@text = text
|
108
|
-
end
|
109
|
-
|
110
|
-
def to_s
|
111
|
-
@text
|
112
|
-
end
|
113
|
-
|
114
|
-
def to_html
|
115
|
-
@text
|
116
|
-
end
|
117
|
-
|
118
|
-
class << self
|
119
|
-
def full_match_regex
|
120
|
-
self::FULL_MATCH_REGEX # Define this in a child class.
|
121
|
-
end
|
122
|
-
|
123
|
-
def front_match_regex
|
124
|
-
self::FRONT_MATCH_REGEX # Define this in a child class.
|
125
|
-
end
|
126
|
-
|
127
|
-
def count_regex
|
128
|
-
self::COUNT_REGEX # Define this in a child class.
|
129
|
-
end
|
130
|
-
|
131
|
-
def matches? text
|
132
|
-
if text =~ front_match_regex
|
133
|
-
count = text.index count_regex
|
134
|
-
if count.nil?
|
135
|
-
return [self.new(text), ""]
|
136
|
-
else
|
137
|
-
return [self.new(text[0 ... count]), text[count .. -1]]
|
138
|
-
end
|
139
|
-
else
|
140
|
-
return nil
|
141
|
-
end
|
142
|
-
end
|
143
|
-
end
|
144
|
-
end
|
145
|
-
|
146
|
-
class BackslashToken < SingleCharacterToken
|
147
|
-
CHARACTER_MATCHED = "\\"
|
148
|
-
end
|
149
|
-
|
150
|
-
class LeftBraceToken < SingleCharacterToken
|
151
|
-
CHARACTER_MATCHED = "{"
|
152
|
-
end
|
153
|
-
|
154
|
-
class RightBraceToken < SingleCharacterToken
|
155
|
-
CHARACTER_MATCHED = "}"
|
156
|
-
end
|
157
|
-
|
158
|
-
|
159
|
-
class EmptyNewlinesToken < StringToken
|
160
|
-
FULL_MATCH_REGEX = /\A\n\n+\z/
|
161
|
-
FRONT_MATCH_REGEX = /\A\n\n+/
|
162
|
-
COUNT_REGEX = /[^\n]/
|
163
|
-
|
164
|
-
def newlines
|
165
|
-
text
|
166
|
-
end
|
167
|
-
end
|
168
|
-
|
169
|
-
class WhitespaceToken < StringToken
|
170
|
-
FULL_MATCH_REGEX = /\A\s+\z/
|
171
|
-
FRONT_MATCH_REGEX = /\A\s+/
|
172
|
-
COUNT_REGEX = /\S/
|
173
|
-
|
174
|
-
def whitespace
|
175
|
-
text
|
176
|
-
end
|
177
|
-
|
178
|
-
def to_html
|
179
|
-
" " # Replace all whitespace tokens with a single space.
|
180
|
-
end
|
181
|
-
end
|
182
|
-
|
183
|
-
class WordToken < StringToken
|
184
|
-
FULL_MATCH_REGEX = /\A[^\s{}\\]+\z/
|
185
|
-
FRONT_MATCH_REGEX = /[^\s{}\\]+/
|
186
|
-
COUNT_REGEX = /[\s{}\\]/
|
187
|
-
|
188
|
-
def to_html
|
189
|
-
html_escape text
|
190
|
-
end
|
191
|
-
|
192
|
-
def word
|
193
|
-
text
|
194
|
-
end
|
195
|
-
end
|
196
|
-
|
197
|
-
class NumberToken < Token
|
198
|
-
attr_reader :number, :text
|
199
|
-
|
200
|
-
def initialize(text)
|
201
|
-
raise ArgumentError if not text.is_a? String
|
202
|
-
@text = text
|
203
|
-
end
|
204
|
-
|
205
|
-
def parse
|
206
|
-
end
|
207
|
-
|
208
|
-
def to_s
|
209
|
-
number.to_s
|
210
|
-
end
|
211
|
-
|
212
|
-
def to_html
|
213
|
-
to_s
|
214
|
-
end
|
215
|
-
|
216
|
-
class << self
|
217
|
-
def matches? text
|
218
|
-
end
|
219
|
-
end
|
220
|
-
end
|
221
|
-
|
222
|
-
class Tokenizer
|
223
|
-
attr_reader :text, :tokens
|
224
|
-
def initialize(text)
|
225
|
-
@text = text
|
226
|
-
tokenize
|
227
|
-
end
|
228
|
-
|
229
|
-
def tokenize
|
230
|
-
@tokens = []
|
231
|
-
rest = text.gsub("\r", "")
|
232
|
-
while rest.length > 0
|
233
|
-
if result = BackslashToken.matches?(rest) or # Single Character Tokens
|
234
|
-
result = LeftBraceToken.matches?(rest) or
|
235
|
-
result = RightBraceToken.matches?(rest) or
|
236
|
-
result = EmptyNewlinesToken.matches?(rest) or # String Tokens
|
237
|
-
result = WhitespaceToken.matches?(rest) or
|
238
|
-
result = NumberToken.matches?(rest) or
|
239
|
-
result = WordToken.matches?(rest)
|
240
|
-
then
|
241
|
-
@tokens << result[0]
|
242
|
-
rest = result[1]
|
243
|
-
else
|
244
|
-
raise RuntimeError, "Couldn't tokenize the remaining text."
|
245
|
-
end
|
246
|
-
end
|
247
|
-
return @tokens
|
248
|
-
end
|
249
|
-
end
|
250
|
-
|
251
|
-
class CommandParser < ParserNode
|
252
|
-
attr_reader :command, :expressions
|
253
|
-
|
254
|
-
def initialize(command, expressions)
|
255
|
-
raise ArgumentError if not command.is_a? WordToken
|
256
|
-
@command = command
|
257
|
-
raise ArgumentError if not expressions.is_a? Array
|
258
|
-
expressions.each {|expression| raise ArgumentError if not expression.kind_of? ParserNode}
|
259
|
-
@expressions = expressions
|
260
|
-
end
|
261
|
-
|
262
|
-
def command_error(message)
|
263
|
-
%{<span style="color: red">[#{message}]</span>}
|
264
|
-
end
|
265
|
-
|
266
|
-
def to_html
|
267
|
-
case command.word
|
268
|
-
when "backslash", "bslash"
|
269
|
-
"\\"
|
270
|
-
when "left-brace", "left_brace", "leftbrace", "lbrace", "opening-brace", "opening_brace",
|
271
|
-
"openingbrace", "obrace"
|
272
|
-
"{"
|
273
|
-
when "right-brace", "right_brace", "rightbrace", "rbrace", "closing-brace", "closing_brace",
|
274
|
-
"closingbrace", "cbrace"
|
275
|
-
"}"
|
276
|
-
when "br", "newline"
|
277
|
-
"\n</br>\n"
|
278
|
-
when "bold", "b", "textbf"
|
279
|
-
html_tag :b
|
280
|
-
when "del", "s", "strike", "strikethrough", "strikeout"
|
281
|
-
html_tag :del
|
282
|
-
when "i"
|
283
|
-
command_error "Complex numbers are not yet supported."
|
284
|
-
when "italic", "textit", "it"
|
285
|
-
html_tag :i
|
286
|
-
when "underline", "u"
|
287
|
-
html_tag :u
|
288
|
-
when "tt", "texttt", "teletype", "typewriter"
|
289
|
-
html_tag :tt
|
290
|
-
when "small"
|
291
|
-
html_tag :small
|
292
|
-
when "big"
|
293
|
-
html_tag :big
|
294
|
-
when "subscript", "sub"
|
295
|
-
html_tag :sub
|
296
|
-
when "superscript", "sup"
|
297
|
-
html_tag :sup
|
298
|
-
when "user", "user-id", "user_id"
|
299
|
-
user_command_handler
|
300
|
-
when "link-id", "link_id"
|
301
|
-
link_id_command_handler
|
302
|
-
when "keyword-id", "keyword_id"
|
303
|
-
keyword_id_command_handler
|
304
|
-
when "tag-id", "tag_id"
|
305
|
-
tag_id_command_handler
|
306
|
-
when "forum-id", "forum_id"
|
307
|
-
forum_id_command_handler
|
308
|
-
when "folder-id", "folder_id"
|
309
|
-
folder_id_command_handler
|
310
|
-
when "bookmarks-folder-id", "bookmarks_folder_id", "bookmarks_folder-id", "bookmarks-folder_id",
|
311
|
-
"bookmark-folder-id", "bookmark_folder_id", "bookmark_folder-id", "bookmark-folder_id"
|
312
|
-
bookmarks_folder_id_command_handler
|
313
|
-
when "pi"
|
314
|
-
"#{Math::PI}"
|
315
|
-
when "e"
|
316
|
-
"#{Math::E}"
|
317
|
-
when "+"
|
318
|
-
injectable_math_function_handler 0, :+
|
319
|
-
when "-"
|
320
|
-
if (numbers = numbers_from_expressions).length == 1
|
321
|
-
injectable_math_function_handler 0, :-
|
322
|
-
else
|
323
|
-
reducable_math_function_handler :-
|
324
|
-
end
|
325
|
-
when "*"
|
326
|
-
injectable_math_function_handler 1, :*
|
327
|
-
when "/"
|
328
|
-
if (numbers = numbers_from_expressions).length == 1
|
329
|
-
1 / numbers.first
|
330
|
-
else
|
331
|
-
reducable_math_function_handler :/
|
332
|
-
end
|
333
|
-
when "%"
|
334
|
-
injectable_math_function_handler numbers_from_expressions.first, :%
|
335
|
-
when "^", "**"
|
336
|
-
number, exponent = numbers_from_expressions
|
337
|
-
number.send :**, exponent
|
338
|
-
when "sin", "cos", "tan",
|
339
|
-
"asin", "acos", "atan",
|
340
|
-
"sinh", "cosh", "tanh",
|
341
|
-
"asinh", "acosh", "atanh",
|
342
|
-
"erf", "erfc",
|
343
|
-
"gamma", "log10", "sqrt"
|
344
|
-
math_function_handler command.word.to_sym
|
345
|
-
when "d2r", "deg->rad", "degrees->radians"
|
346
|
-
MathFunctions::degrees2radians number_from_expression
|
347
|
-
when "r2d", "rad->deg", "radians->degrees"
|
348
|
-
MathFunctions::radians2degrees number_from_expression
|
349
|
-
when "lgamma"
|
350
|
-
MathFunctions::lgamma number_from_expression
|
351
|
-
when "ld", "log2"
|
352
|
-
Math.log2 number_from_expression
|
353
|
-
when "ln"
|
354
|
-
Math.log number_from_expression
|
355
|
-
when "log"
|
356
|
-
base, number = numbers_from_expressions
|
357
|
-
if number.nil?
|
358
|
-
Math.log base
|
359
|
-
else
|
360
|
-
Math.log number, base
|
361
|
-
end
|
362
|
-
when "ldexp"
|
363
|
-
fraction, exponent = numbers_from_expressions
|
364
|
-
Math.ldexp fraction, exponent
|
365
|
-
when "hypot"
|
366
|
-
Math.sqrt numbers_from_expressions.map {|n| n**2}
|
367
|
-
else
|
368
|
-
command_error "unknown command #{command.to_html}"
|
369
|
-
end
|
370
|
-
end
|
371
|
-
|
372
|
-
def html_tag(tag)
|
373
|
-
"<#{tag}>" + expressions.map(&:to_html).join + "</#{tag}>"
|
374
|
-
end
|
375
|
-
|
376
|
-
def tb_href(target, string)
|
377
|
-
%{<a href="#{TB_COM}/#{target}">#{string}</a>}
|
378
|
-
end
|
379
|
-
|
380
|
-
def numbers_from_expressions
|
381
|
-
expressions
|
382
|
-
.map do |number|
|
383
|
-
begin
|
384
|
-
Float(number.to_html)
|
385
|
-
rescue ArgumentError
|
386
|
-
nil
|
387
|
-
end
|
388
|
-
end.reject &:nil?
|
389
|
-
end
|
390
|
-
|
391
|
-
def number_from_expression
|
392
|
-
numbers_from_expressions.first
|
393
|
-
end
|
394
|
-
|
395
|
-
def injectable_math_function_handler(initial, function)
|
396
|
-
numbers_from_expressions.inject initial, function
|
397
|
-
end
|
398
|
-
|
399
|
-
def reducable_math_function_handler(function)
|
400
|
-
numbers_from_expressions.reduce function
|
401
|
-
end
|
402
|
-
|
403
|
-
def math_function_handler(function)
|
404
|
-
Math.send function, numbers_from_expressions.first
|
405
|
-
end
|
406
|
-
|
407
|
-
def user_command_handler
|
408
|
-
user = expressions.select {|expr| expr.is_a? WordToken}.first
|
409
|
-
if not user
|
410
|
-
command_error "user: error: no user specified"
|
411
|
-
else
|
412
|
-
if @@action_view.kind_of? ActionView::Base
|
413
|
-
the_user = User.smart_find user.to_s
|
414
|
-
if the_user
|
415
|
-
@@action_view.render partial: 'users/name_link',
|
416
|
-
locals: {the_user: the_user}
|
417
|
-
else
|
418
|
-
command_error "unknown user #{user.to_s}"
|
419
|
-
end
|
420
|
-
else
|
421
|
-
tb_href "users/#{user}", user.to_s
|
422
|
-
end
|
423
|
-
end
|
424
|
-
end
|
425
|
-
|
426
|
-
def id_command_handler(klass,
|
427
|
-
singular = klass.to_s.camelcase_to_snakecase,
|
428
|
-
plural = singular.pluralize,
|
429
|
-
partial = "#{plural}/inline",
|
430
|
-
view="")
|
431
|
-
id = expressions.select {|expr| expr.is_a? WordToken}.first
|
432
|
-
if not id
|
433
|
-
command_error "#{singular}_id: error: no #{singular} ID specified"
|
434
|
-
elsif not id.to_s =~ /\A[0-9]+\z/
|
435
|
-
command_error "#{singular}_id: error: invalid #{singular} ID specified"
|
436
|
-
else
|
437
|
-
if @@action_view.kind_of? ActionView::Base
|
438
|
-
thing = klass.find Integer(id.to_s)
|
439
|
-
if thing
|
440
|
-
@@action_view.render partial: partial,
|
441
|
-
locals: {singular.to_sym => thing}
|
442
|
-
else
|
443
|
-
command_error "unknown #{singular} ID #{id.to_s}"
|
444
|
-
end
|
445
|
-
else
|
446
|
-
tb_href "/#{plural}/#{id.to_s}/#{view}", "#{klass} ##{id.to_s}"
|
447
|
-
end
|
448
|
-
end
|
449
|
-
end
|
450
|
-
|
451
|
-
def link_id_command_handler
|
452
|
-
id_command_handler Link
|
453
|
-
end
|
454
|
-
|
455
|
-
def tag_id_command_handler
|
456
|
-
id_command_handler Tag
|
457
|
-
end
|
458
|
-
|
459
|
-
def folder_id_command_handler
|
460
|
-
id_command_handler Folder
|
461
|
-
end
|
462
|
-
|
463
|
-
def forum_id_command_handler
|
464
|
-
id_command_handler Forum
|
465
|
-
end
|
466
|
-
|
467
|
-
def bookmarks_folder_id_command_handler
|
468
|
-
id_command_handler Folder, "folder", "folders", "folders/bookmarks_inline", "bookmarks"
|
469
|
-
end
|
470
|
-
|
471
|
-
class << self
|
472
|
-
@@action_view = nil
|
473
|
-
@@controller = nil
|
474
|
-
|
475
|
-
def parse(tokens)
|
476
|
-
expressions = []
|
477
|
-
rest = tokens
|
478
|
-
backslash, command, left_brace = rest.shift(3)
|
479
|
-
right_brace = nil
|
480
|
-
raise ParseError if not backslash.is_a? BackslashToken
|
481
|
-
raise ParseError if not command.is_a? WordToken
|
482
|
-
if not left_brace.is_a? LeftBraceToken # A command with no interior.
|
483
|
-
rest.unshift left_brace if not left_brace.is_a? WhitespaceToken
|
484
|
-
return [CommandParser.new(command, []), rest]
|
485
|
-
end
|
486
|
-
while rest.length > 0
|
487
|
-
if rest.first.is_a? WordToken
|
488
|
-
expressions << rest.shift
|
489
|
-
elsif rest.first.is_a? WhitespaceToken
|
490
|
-
expressions << rest.shift
|
491
|
-
elsif rest.first.is_a? BackslashToken
|
492
|
-
result, rest = CommandParser.parse(rest)
|
493
|
-
expressions << result
|
494
|
-
elsif rest.first.is_a? RightBraceToken
|
495
|
-
right_brace = rest.shift
|
496
|
-
return [CommandParser.new(command, expressions), rest]
|
497
|
-
else
|
498
|
-
raise ParseError
|
499
|
-
end
|
500
|
-
end
|
501
|
-
if right_brace.nil? # Allow a forgotten final right brace.
|
502
|
-
return [CommandParser.new(command, expressions), rest]
|
503
|
-
end
|
504
|
-
end
|
505
|
-
|
506
|
-
def action_view=(new)
|
507
|
-
@@action_view = new
|
508
|
-
end
|
509
|
-
|
510
|
-
def controller=(new)
|
511
|
-
@@controller = new
|
512
|
-
end
|
513
|
-
end
|
514
|
-
end
|
515
|
-
|
516
|
-
class ParagraphParser < ParserNode
|
517
|
-
attr_reader :expressions, :tokens
|
518
|
-
|
519
|
-
def initialize(tokens)
|
520
|
-
raise ArgumentError if not tokens.is_a? Array
|
521
|
-
tokens.each {|token| raise ArgumentError if not token.kind_of? ParserNode}
|
522
|
-
@tokens = tokens
|
523
|
-
parse
|
524
|
-
end
|
525
|
-
|
526
|
-
def parse
|
527
|
-
@expressions = []
|
528
|
-
rest = tokens
|
529
|
-
while rest.length > 0
|
530
|
-
if rest.first.is_a? WordToken
|
531
|
-
@expressions << rest.shift
|
532
|
-
elsif rest.first.is_a? WhitespaceToken
|
533
|
-
@expressions << rest.shift
|
534
|
-
elsif rest.first.is_a? BackslashToken
|
535
|
-
command, rest = CommandParser.parse(rest)
|
536
|
-
@expressions << command
|
537
|
-
else
|
538
|
-
return self
|
539
|
-
end
|
540
|
-
end
|
541
|
-
end
|
542
|
-
|
543
|
-
def to_html
|
544
|
-
"<p>\n" + expressions.map(&:to_html).join + "\n</p>\n"
|
545
|
-
end
|
546
|
-
end
|
547
|
-
|
548
|
-
class Parser < ParserNode
|
549
|
-
attr_reader :paragraphs, :split_tokens, :text, :tokenizer
|
550
|
-
|
551
|
-
def tokens
|
552
|
-
tokenizer.tokens
|
553
|
-
end
|
39
|
+
end
|
554
40
|
|
555
|
-
|
556
|
-
@text = text
|
557
|
-
@tokenizer = Tokenizer.new text
|
558
|
-
parse
|
559
|
-
end
|
41
|
+
include ERB::Util
|
560
42
|
|
561
|
-
|
562
|
-
|
563
|
-
@paragraphs = @split_tokens.map {|split_tokens| ParagraphParser.new split_tokens}
|
564
|
-
end
|
43
|
+
require 'active_support/all'
|
44
|
+
require 'monkey-patch'
|
565
45
|
|
566
|
-
|
567
|
-
|
568
|
-
|
569
|
-
|
570
|
-
|
46
|
+
require 'teepee/constants'
|
47
|
+
require 'teepee/errors'
|
48
|
+
require 'teepee/parser-node'
|
49
|
+
require 'teepee/token'
|
50
|
+
require 'teepee/commander'
|
51
|
+
require 'teepee/actionable-commander'
|
52
|
+
require 'teepee/single-character-token'
|
53
|
+
require 'teepee/string-token'
|
54
|
+
require 'teepee/number-token'
|
55
|
+
require 'teepee/tokenizer'
|
56
|
+
require 'teepee/command-parser'
|
57
|
+
require 'teepee/paragraph-parser'
|
58
|
+
require 'teepee/parser'
|