rubypants 0.2.0 → 0.7.1

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/rubypants.rb ADDED
@@ -0,0 +1,500 @@
1
+ require_relative 'version'
2
+
3
+ class RubyPants < String
4
+ extend RubyPantsVersion
5
+
6
+ # Create a new RubyPants instance with the text in +string+.
7
+ #
8
+ # Allowed elements in the options array:
9
+ #
10
+ # 0 :: do nothing
11
+ # 1 :: enable all, using only em-dash shortcuts
12
+ # 2 :: enable all, using old school en- and em-dash shortcuts (*default*)
13
+ # 3 :: enable all, using inverted old school en and em-dash shortcuts
14
+ # -1 :: stupefy (translate HTML entities to their ASCII-counterparts)
15
+ #
16
+ # If you don't like any of these defaults, you can pass symbols to change
17
+ # RubyPants' behavior:
18
+ #
19
+ # <tt>:quotes</tt> :: quotes
20
+ # <tt>:backticks</tt> :: backtick quotes (``double'' only)
21
+ # <tt>:allbackticks</tt> :: backtick quotes (``double'' and `single')
22
+ # <tt>:dashes</tt> :: dashes
23
+ # <tt>:oldschool</tt> :: old school dashes
24
+ # <tt>:inverted</tt> :: inverted old school dashes
25
+ # <tt>:ellipses</tt> :: ellipses
26
+ # <tt>:prevent_breaks</tt> :: use nbsp and word-joiner to avoid breaking
27
+ # before dashes and ellipses
28
+ # <tt>:named_entities</tt> :: used named entities instead of the default
29
+ # decimal entities (see below)
30
+ # <tt>:convertquotes</tt> :: convert <tt>&quot;</tt> entities to
31
+ # <tt>"</tt>
32
+ # <tt>:stupefy</tt> :: translate RubyPants HTML entities
33
+ # to their ASCII counterparts.
34
+ #
35
+ # In addition, you can customize the HTML entities that will be injected by
36
+ # passing in a hash for the final argument. The defaults for these entities
37
+ # are as follows:
38
+ #
39
+ # <tt>:single_left_quote</tt> :: <tt>&#8216;</tt>
40
+ # <tt>:double_left_quote</tt> :: <tt>&#8220;</tt>
41
+ # <tt>:single_right_quote</tt> :: <tt>&#8217;</tt>
42
+ # <tt>:double_right_quote</tt> :: <tt>&#8221;</tt>
43
+ # <tt>:em_dash</tt> :: <tt>&#8212;</tt>
44
+ # <tt>:en_dash</tt> :: <tt>&#8211;</tt>
45
+ # <tt>:ellipsis</tt> :: <tt>&#8230;</tt>
46
+ # <tt>:non_breaking_space</tt> :: <tt>&nbsp;</tt>
47
+ # <tt>:word_joiner</tt> :: <tt>&#8288;</tt>
48
+ #
49
+ # If the <tt>:named_entities</tt> option is used, the default entities are
50
+ # as follows:
51
+ #
52
+ # <tt>:single_left_quote</tt> :: <tt>&lsquo;</tt>
53
+ # <tt>:double_left_quote</tt> :: <tt>&ldquo;</tt>
54
+ # <tt>:single_right_quote</tt> :: <tt>&rsquo;</tt>
55
+ # <tt>:double_right_quote</tt> :: <tt>&rdquo;</tt>
56
+ # <tt>:em_dash</tt> :: <tt>&mdash;</tt>
57
+ # <tt>:en_dash</tt> :: <tt>&ndash;</tt>
58
+ # <tt>:ellipsis</tt> :: <tt>&hellip;</tt>
59
+ # <tt>:non_breaking_space</tt> :: <tt>&nbsp;</tt>
60
+ # <tt>:word_joiner</tt> :: <tt>&#8288;</tt>
61
+ #
62
+ # If the <tt>:character_entities</tt> option is used, RubyPants will
63
+ # emit Unicode characters directly, rather than HTML entities. By default
64
+ # this excludes the space characters (non-breaking space and
65
+ # word-joiner). To additionally emit Unicode space characters, use the
66
+ # <tt>:character_spaces</tt> option.
67
+ #
68
+ def initialize(string, options=[2], entities = {})
69
+ super string
70
+
71
+ @options = [*options]
72
+ @entities = default_entities
73
+ @entities.merge!(named_entities) if @options.include?(:named_entities)
74
+ @entities.merge!(character_entities) if @options.include?(:character_entities)
75
+ @entities.merge!(character_spaces) if @options.include?(:character_spaces)
76
+ @entities.merge!(entities)
77
+
78
+ @single_left_quote = @entities[:single_left_quote]
79
+ @single_right_quote = @entities[:single_right_quote]
80
+ @double_left_quote = @entities[:double_left_quote]
81
+ @double_right_quote = @entities[:double_right_quote]
82
+ @ellipsis = @entities[:ellipsis]
83
+ @em_dash = @entities[:em_dash]
84
+ @en_dash = @entities[:en_dash]
85
+ end
86
+
87
+ SPECIAL_HTML_TAGS = %r!\A<(/?)(pre|code|kbd|script|style|math)[\s>]!
88
+ NON_WHITESPACE_CHARS = /\S/
89
+
90
+ # Apply SmartyPants transformations.
91
+ def to_html
92
+ do_quotes = do_backticks = do_dashes = do_ellipses = do_stupify = nil
93
+ convert_quotes = prevent_breaks = nil
94
+
95
+ if @options.include?(0)
96
+ # Do nothing.
97
+ return self
98
+ elsif @options.include?(1)
99
+ # Do everything, turn all options on.
100
+ do_quotes = do_backticks = do_ellipses = true
101
+ do_dashes = :normal
102
+ elsif @options.include?(2)
103
+ # Do everything, turn all options on, use old school dash shorthand.
104
+ do_quotes = do_backticks = do_ellipses = true
105
+ do_dashes = :oldschool
106
+ elsif @options.include?(3)
107
+ # Do everything, turn all options on, use inverted old school
108
+ # dash shorthand.
109
+ do_quotes = do_backticks = do_ellipses = true
110
+ do_dashes = :inverted
111
+ elsif @options.include?(-1)
112
+ do_stupefy = true
113
+ end
114
+
115
+ # Explicit flags override numeric flag groups.
116
+ do_quotes = true if @options.include?(:quotes)
117
+ do_backticks = true if @options.include?(:backticks)
118
+ do_backticks = :both if @options.include?(:allbackticks)
119
+ do_dashes = :normal if @options.include?(:dashes)
120
+ do_dashes = :oldschool if @options.include?(:oldschool)
121
+ do_dashes = :inverted if @options.include?(:inverted)
122
+ prevent_breaks = true if @options.include?(:prevent_breaks)
123
+ do_ellipses = true if @options.include?(:ellipses)
124
+ convert_quotes = true if @options.include?(:convertquotes)
125
+ do_stupefy = true if @options.include?(:stupefy)
126
+
127
+ # Parse the HTML
128
+ tokens = tokenize
129
+
130
+ # Keep track of when we're inside <pre> or <code> tags.
131
+ in_pre = nil
132
+
133
+ # Here is the result stored in.
134
+ result = ""
135
+
136
+ # This is a cheat, used to get some context for one-character
137
+ # tokens that consist of just a quote char. What we do is remember
138
+ # the last character of the previous text token, to use as context
139
+ # to curl single- character quote tokens correctly.
140
+ prev_token_last_char = nil
141
+
142
+ tokens.each do |token|
143
+ if token.first == :tag
144
+ result << token[1]
145
+ if token[1].end_with? '/>'
146
+ # ignore self-closing tags
147
+ elsif token[1] =~ SPECIAL_HTML_TAGS
148
+ if $1 == '' && ! in_pre
149
+ in_pre = $2
150
+ elsif $1 == '/' && $2 == in_pre
151
+ in_pre = nil
152
+ end
153
+ end
154
+ else
155
+ t = token[1]
156
+
157
+ # Remember last char of this token before processing.
158
+ last_char = t[-1].chr
159
+
160
+ unless in_pre
161
+ t = process_escapes t
162
+
163
+ t.gsub!('&quot;', '"') if convert_quotes
164
+
165
+ if do_dashes
166
+ t = educate_dashes t, prevent_breaks if do_dashes == :normal
167
+ t = educate_dashes_oldschool t, prevent_breaks if do_dashes == :oldschool
168
+ t = educate_dashes_inverted t, prevent_breaks if do_dashes == :inverted
169
+ end
170
+
171
+ t = educate_ellipses t, prevent_breaks if do_ellipses
172
+
173
+ # Note: backticks need to be processed before quotes.
174
+ if do_backticks
175
+ t = educate_backticks t
176
+ t = educate_single_backticks t if do_backticks == :both
177
+ end
178
+
179
+ if do_quotes
180
+ if t == "'"
181
+ # Special case: single-character ' token
182
+ if prev_token_last_char =~ NON_WHITESPACE_CHARS
183
+ t = @single_right_quote
184
+ else
185
+ t = @single_left_quote
186
+ end
187
+ elsif t == '"'
188
+ # Special case: single-character " token
189
+ if prev_token_last_char =~ NON_WHITESPACE_CHARS
190
+ t = @double_right_quote
191
+ else
192
+ t = @double_left_quote
193
+ end
194
+ else
195
+ # Normal case:
196
+ t = educate_quotes t
197
+ end
198
+ end
199
+
200
+ t = stupefy_entities t if do_stupefy
201
+ end
202
+
203
+ prev_token_last_char = last_char
204
+ result << t
205
+ end
206
+ end
207
+
208
+ # Done
209
+ result
210
+ end
211
+
212
+ protected
213
+
214
+ # Return the string, with after processing the following backslash
215
+ # escape sequences. This is useful if you want to force a "dumb" quote
216
+ # or other character to appear.
217
+ #
218
+ # Escaped are:
219
+ # \\ \" \' \. \- \`
220
+ #
221
+ def process_escapes(str)
222
+ str.
223
+ gsub('\\\\', '&#92;').
224
+ gsub('\"', '&#34;').
225
+ gsub("\\\'", '&#39;').
226
+ gsub('\.', '&#46;').
227
+ gsub('\-', '&#45;').
228
+ gsub('\`', '&#96;')
229
+ end
230
+
231
+ def self.n_of(n, x)
232
+ x = Regexp.escape(x)
233
+ /(?<!#{x}) # not preceded by x
234
+ #{x}{#{n}} # n of x
235
+ (?!#{x}) # not followed by x
236
+ /x
237
+ end
238
+
239
+ DOUBLE_DASH = n_of(2, '-')
240
+ TRIPLE_DASH = n_of(3, '-')
241
+ TRIPLE_DOTS = n_of(3, '.')
242
+
243
+ # Return +str+ replacing all +patt+ with +repl+. If +prevent_breaks+ is true,
244
+ # then replace spaces preceding +patt+ with a non-breaking space, and if there
245
+ # are no spaces, then insert a word-joiner.
246
+ #
247
+ def educate(str, patt, repl, prevent_breaks)
248
+ patt = /(?<spaces>[[:space:]]*)#{patt}/
249
+ str.gsub(patt) do
250
+ spaces = if prevent_breaks && $~['spaces'].length > 0
251
+ entity(:non_breaking_space) # * $~['spaces'].length
252
+ elsif prevent_breaks
253
+ entity(:word_joiner)
254
+ else
255
+ $~['spaces']
256
+ end
257
+ spaces + repl
258
+ end
259
+ end
260
+
261
+ # Return the string, with each instance of "<tt>--</tt>" translated to an
262
+ # em-dash HTML entity.
263
+ #
264
+ def educate_dashes(str, prevent_breaks=false)
265
+ educate(str, DOUBLE_DASH, @em_dash, prevent_breaks)
266
+ end
267
+
268
+ # Return the string, with each instance of "<tt>--</tt>" translated to an
269
+ # en-dash HTML entity, and each "<tt>---</tt>" translated to an
270
+ # em-dash HTML entity.
271
+ #
272
+ def educate_dashes_oldschool(str, prevent_breaks=false)
273
+ str = educate(str, TRIPLE_DASH, @em_dash, prevent_breaks)
274
+ educate(str, DOUBLE_DASH, @en_dash, prevent_breaks)
275
+ end
276
+
277
+ # Return the string, with each instance of "<tt>--</tt>" translated
278
+ # to an em-dash HTML entity, and each "<tt>---</tt>" translated to
279
+ # an en-dash HTML entity. Two reasons why: First, unlike the en- and
280
+ # em-dash syntax supported by +educate_dashes_oldschool+, it's
281
+ # compatible with existing entries written before SmartyPants 1.1,
282
+ # back when "<tt>--</tt>" was only used for em-dashes. Second,
283
+ # em-dashes are more common than en-dashes, and so it sort of makes
284
+ # sense that the shortcut should be shorter to type. (Thanks to
285
+ # Aaron Swartz for the idea.)
286
+ #
287
+ def educate_dashes_inverted(str, prevent_breaks=false)
288
+ str = educate(str, TRIPLE_DASH, @en_dash, prevent_breaks)
289
+ educate(str, DOUBLE_DASH, @em_dash, prevent_breaks)
290
+ end
291
+
292
+ SPACED_ELLIPSIS_PATTERN = /(?<!\.|\.[ ])\.[ ]\.[ ]\.(?!\.|[ ]\.)/
293
+
294
+ # Return the string, with each instance of "<tt>...</tt>" translated
295
+ # to an ellipsis HTML entity. Also converts the case where there are
296
+ # spaces between the dots.
297
+ #
298
+ def educate_ellipses(str, prevent_breaks=false)
299
+ str = educate(str, TRIPLE_DOTS, @ellipsis, prevent_breaks)
300
+ educate(str, SPACED_ELLIPSIS_PATTERN,
301
+ @ellipsis, prevent_breaks)
302
+ end
303
+
304
+ # Return the string, with "<tt>``backticks''</tt>"-style single quotes
305
+ # translated into HTML curly quote entities.
306
+ #
307
+ def educate_backticks(str)
308
+ str.
309
+ gsub("``", @double_left_quote).
310
+ gsub("''", @double_right_quote)
311
+ end
312
+
313
+ # Return the string, with "<tt>`backticks'</tt>"-style single quotes
314
+ # translated into HTML curly quote entities.
315
+ #
316
+ def educate_single_backticks(str)
317
+ str.
318
+ gsub("`", @single_left_quote).
319
+ gsub("'", @single_right_quote)
320
+ end
321
+
322
+ PUNCT_CLASS = '[!"#\$\%\'()*+,\-.\/:;<=>?\@\[\\\\\]\^_`{|}~]'.freeze
323
+ SNGL_QUOT_PUNCT_CASE = /^'(?=#{PUNCT_CLASS}\B)/
324
+ DBLE_QUOT_PUNCT_CASE = /^"(?=#{PUNCT_CLASS}\B)/
325
+
326
+ STARTS_MIXED_QUOTS_WITH_SNGL = /'"(?=\w)/
327
+ STARTS_MIXED_QUOTS_WITH_DBLE = /"'(?=\w)/
328
+
329
+ DECADE_ABBR_CASE = /'(?=\d\ds)/
330
+
331
+ CLOSE_CLASS = '[^\ \t\r\n\\[\{\(\-]'.freeze
332
+ CHAR_LEADS_SNGL_QUOTE = /(#{CLOSE_CLASS})'/
333
+ WHITESPACE_TRAILS_SNGL_QUOTE = /'(\s|s\b|$)/
334
+ CHAR_LEADS_DBLE_QUOTE = /(#{CLOSE_CLASS})"/
335
+ WHITESPACE_TRAILS_DBLE_QUOTE = /"(\s|s\b|$)/
336
+
337
+ # Return the string, with "educated" curly quote HTML entities.
338
+ #
339
+ def educate_quotes(str)
340
+ str = str.dup
341
+
342
+ # Special case if the very first character is a quote followed by
343
+ # punctuation at a non-word-break. Close the quotes by brute
344
+ # force:
345
+ str.gsub!(SNGL_QUOT_PUNCT_CASE,
346
+ @single_right_quote)
347
+ str.gsub!(DBLE_QUOT_PUNCT_CASE,
348
+ @double_right_quote)
349
+
350
+ # Special case for double sets of quotes, e.g.:
351
+ # <p>He said, "'Quoted' words in a larger quote."</p>
352
+ str.gsub!(STARTS_MIXED_QUOTS_WITH_DBLE,
353
+ "#{@double_left_quote}#{@single_left_quote}")
354
+ str.gsub!(STARTS_MIXED_QUOTS_WITH_SNGL,
355
+ "#{@single_left_quote}#{@double_left_quote}")
356
+
357
+ # Special case for decade abbreviations (the '80s):
358
+ str.gsub!(DECADE_ABBR_CASE,
359
+ @single_right_quote)
360
+
361
+ dec_dashes = "#{@en_dash}|#{@em_dash}"
362
+ quote_precedent = "[[:space:]]|&nbsp;|--|&[mn]dash;|#{dec_dashes}|&#x201[34];"
363
+
364
+ # Get most opening single quotes:
365
+ str.gsub!(/(#{quote_precedent})'(?=\w)/,
366
+ '\1' + @single_left_quote)
367
+
368
+ # Single closing quotes:
369
+ str.gsub!(CHAR_LEADS_SNGL_QUOTE,
370
+ '\1' + @single_right_quote)
371
+ str.gsub!(WHITESPACE_TRAILS_SNGL_QUOTE,
372
+ @single_right_quote + '\1')
373
+
374
+ # Any remaining single quotes should be opening ones:
375
+ str.gsub!("'",
376
+ @single_left_quote)
377
+
378
+ # Get most opening double quotes:
379
+ str.gsub!(/(#{quote_precedent})"(?=\w)/,
380
+ '\1' + @double_left_quote)
381
+
382
+ # Double closing quotes:
383
+ str.gsub!(CHAR_LEADS_DBLE_QUOTE,
384
+ '\1' + @double_right_quote)
385
+ str.gsub!(WHITESPACE_TRAILS_DBLE_QUOTE,
386
+ @double_right_quote + '\1')
387
+
388
+ # Any remaining quotes should be opening ones:
389
+ str.gsub!('"',
390
+ @double_left_quote)
391
+
392
+ str
393
+ end
394
+
395
+ # Return the string, with each RubyPants HTML entity translated to
396
+ # its ASCII counterpart.
397
+ #
398
+ # Note: This is not reversible (but exactly the same as in SmartyPants)
399
+ #
400
+ def stupefy_entities(str)
401
+ new_str = str.dup
402
+
403
+ {
404
+ :en_dash => '-',
405
+ :em_dash => '--',
406
+ :single_left_quote => "'",
407
+ :single_right_quote => "'",
408
+ :double_left_quote => '"',
409
+ :double_right_quote => '"',
410
+ :ellipsis => '...'
411
+ }.each do |k,v|
412
+ new_str.gsub!(entity(k).to_s, v)
413
+ end
414
+
415
+ new_str
416
+ end
417
+
418
+ TAG_SOUP = /([^<]*)(<!--.*?-->|<[^>]*>)/m
419
+
420
+ # Return an array of the tokens comprising the string. Each token is
421
+ # either a tag (possibly with nested, tags contained therein, such
422
+ # as <tt><a href="<MTFoo>"></tt>, or a run of text between
423
+ # tags. Each element of the array is a two-element array; the first
424
+ # is either :tag or :text; the second is the actual value.
425
+ #
426
+ # Based on the <tt>_tokenize()</tt> subroutine from Brad Choate's
427
+ # MTRegex plugin. <http://www.bradchoate.com/past/mtregex.php>
428
+ #
429
+ # This is actually the easier variant using tag_soup, as used by
430
+ # Chad Miller in the Python port of SmartyPants.
431
+ #
432
+ def tokenize
433
+ tokens = []
434
+
435
+ prev_end = 0
436
+
437
+ scan(TAG_SOUP) do
438
+ tokens << [:text, $1] if $1 != ""
439
+ tokens << [:tag, $2]
440
+ prev_end = $~.end(0)
441
+ end
442
+
443
+ if prev_end < size
444
+ tokens << [:text, self[prev_end..-1]]
445
+ end
446
+
447
+ tokens
448
+ end
449
+
450
+ def default_entities
451
+ {
452
+ :single_left_quote => "&#8216;",
453
+ :double_left_quote => "&#8220;",
454
+ :single_right_quote => "&#8217;",
455
+ :double_right_quote => "&#8221;",
456
+ :em_dash => "&#8212;",
457
+ :en_dash => "&#8211;",
458
+ :ellipsis => "&#8230;",
459
+ :non_breaking_space => "&nbsp;",
460
+ :word_joiner => "&#8288;",
461
+ }
462
+ end
463
+
464
+ def named_entities
465
+ {
466
+ :single_left_quote => '&lsquo;',
467
+ :double_left_quote => "&ldquo;",
468
+ :single_right_quote => "&rsquo;",
469
+ :double_right_quote => "&rdquo;",
470
+ :em_dash => "&mdash;",
471
+ :en_dash => "&ndash;",
472
+ :ellipsis => "&hellip;",
473
+ :non_breaking_space => "&nbsp;",
474
+ # :word_joiner => N/A,
475
+ }
476
+ end
477
+
478
+ def character_entities
479
+ {
480
+ :single_left_quote => "\u2018",
481
+ :double_left_quote => "\u201C",
482
+ :single_right_quote => "\u2019",
483
+ :double_right_quote => "\u201D",
484
+ :em_dash => "\u2014",
485
+ :en_dash => "\u2013",
486
+ :ellipsis => "\u2026",
487
+ }
488
+ end
489
+
490
+ def character_spaces
491
+ {
492
+ :non_breaking_space => "\u00A0",
493
+ :word_joiner => "\u2060",
494
+ }
495
+ end
496
+
497
+ def entity(key)
498
+ @entities[key]
499
+ end
500
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,3 @@
1
+ module RubyPantsVersion
2
+ VERSION = "0.7.1"
3
+ end
data/rubypants.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ lib = File.expand_path('../lib', __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'version'
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = 'rubypants'
9
+ s.version = RubyPantsVersion::VERSION
10
+ s.summary = "RubyPants is a Ruby port of the smart-quotes library SmartyPants."
11
+ s.description = <<-EOF
12
+ The original "SmartyPants" is a free web publishing plug-in for
13
+ Movable Type, Blosxom, and BBEdit that easily translates plain ASCII
14
+ punctuation characters into "smart" typographic punctuation HTML
15
+ entities.
16
+ EOF
17
+ s.authors = [
18
+ "John Gruber",
19
+ "Chad Miller",
20
+ "Christian Neukirchen",
21
+ "Jeremy McNevin",
22
+ "Aron Griffis"
23
+ ]
24
+ s.email = 'jeremy@spokoino.net'
25
+ s.files = `git ls-files`.split($/)
26
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
27
+ s.require_paths = ["lib"]
28
+ s.homepage = 'https://github.com/jmcnevin/rubypants'
29
+ s.license = 'MIT'
30
+
31
+ s.add_development_dependency('minitest')
32
+ end
data/test/helper.rb ADDED
@@ -0,0 +1,5 @@
1
+ require 'simplecov'
2
+ SimpleCov.start
3
+
4
+ require 'codecov'
5
+ SimpleCov.formatter = SimpleCov::Formatter::Codecov