entreprise7pro-bootstrap-sass 3.4.6

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.
Files changed (171) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +20 -0
  3. data/.travis.yml +19 -0
  4. data/CHANGELOG.md +233 -0
  5. data/CONTRIBUTING.md +86 -0
  6. data/Gemfile +7 -0
  7. data/LICENSE +22 -0
  8. data/README.md +376 -0
  9. data/Rakefile +98 -0
  10. data/assets/fonts/bootstrap/glyphicons-halflings-regular.eot +0 -0
  11. data/assets/fonts/bootstrap/glyphicons-halflings-regular.svg +288 -0
  12. data/assets/fonts/bootstrap/glyphicons-halflings-regular.ttf +0 -0
  13. data/assets/fonts/bootstrap/glyphicons-halflings-regular.woff +0 -0
  14. data/assets/fonts/bootstrap/glyphicons-halflings-regular.woff2 +0 -0
  15. data/assets/images/.keep +0 -0
  16. data/assets/javascripts/bootstrap/affix.js +166 -0
  17. data/assets/javascripts/bootstrap/alert.js +97 -0
  18. data/assets/javascripts/bootstrap/button.js +136 -0
  19. data/assets/javascripts/bootstrap/carousel.js +248 -0
  20. data/assets/javascripts/bootstrap/collapse.js +214 -0
  21. data/assets/javascripts/bootstrap/dropdown.js +167 -0
  22. data/assets/javascripts/bootstrap/modal.js +356 -0
  23. data/assets/javascripts/bootstrap/popover.js +123 -0
  24. data/assets/javascripts/bootstrap/scrollspy.js +174 -0
  25. data/assets/javascripts/bootstrap/tab.js +157 -0
  26. data/assets/javascripts/bootstrap/tooltip.js +679 -0
  27. data/assets/javascripts/bootstrap/transition.js +61 -0
  28. data/assets/javascripts/bootstrap-sprockets.js +12 -0
  29. data/assets/javascripts/bootstrap.js +2611 -0
  30. data/assets/javascripts/bootstrap.min.js +7 -0
  31. data/assets/javascripts/jquery.min.js +2 -0
  32. data/assets/stylesheets/_bootstrap-compass.scss +9 -0
  33. data/assets/stylesheets/_bootstrap-mincer.scss +19 -0
  34. data/assets/stylesheets/_bootstrap-sprockets.scss +9 -0
  35. data/assets/stylesheets/_bootstrap.scss +56 -0
  36. data/assets/stylesheets/bootstrap/_alerts.scss +73 -0
  37. data/assets/stylesheets/bootstrap/_badges.scss +68 -0
  38. data/assets/stylesheets/bootstrap/_breadcrumbs.scss +28 -0
  39. data/assets/stylesheets/bootstrap/_button-groups.scss +244 -0
  40. data/assets/stylesheets/bootstrap/_buttons.scss +168 -0
  41. data/assets/stylesheets/bootstrap/_carousel.scss +263 -0
  42. data/assets/stylesheets/bootstrap/_close.scss +37 -0
  43. data/assets/stylesheets/bootstrap/_code.scss +70 -0
  44. data/assets/stylesheets/bootstrap/_component-animations.scss +38 -0
  45. data/assets/stylesheets/bootstrap/_dropdowns.scss +212 -0
  46. data/assets/stylesheets/bootstrap/_forms.scss +602 -0
  47. data/assets/stylesheets/bootstrap/_glyphicons.scss +307 -0
  48. data/assets/stylesheets/bootstrap/_grid.scss +94 -0
  49. data/assets/stylesheets/bootstrap/_input-groups.scss +166 -0
  50. data/assets/stylesheets/bootstrap/_jumbotron.scss +55 -0
  51. data/assets/stylesheets/bootstrap/_labels.scss +66 -0
  52. data/assets/stylesheets/bootstrap/_list-group.scss +128 -0
  53. data/assets/stylesheets/bootstrap/_media.scss +66 -0
  54. data/assets/stylesheets/bootstrap/_mixins.scss +39 -0
  55. data/assets/stylesheets/bootstrap/_modals.scss +150 -0
  56. data/assets/stylesheets/bootstrap/_navbar.scss +657 -0
  57. data/assets/stylesheets/bootstrap/_navs.scss +242 -0
  58. data/assets/stylesheets/bootstrap/_normalize.scss +422 -0
  59. data/assets/stylesheets/bootstrap/_pager.scss +54 -0
  60. data/assets/stylesheets/bootstrap/_pagination.scss +86 -0
  61. data/assets/stylesheets/bootstrap/_panels.scss +271 -0
  62. data/assets/stylesheets/bootstrap/_popovers.scss +126 -0
  63. data/assets/stylesheets/bootstrap/_print.scss +99 -0
  64. data/assets/stylesheets/bootstrap/_progress-bars.scss +87 -0
  65. data/assets/stylesheets/bootstrap/_responsive-embed.scss +35 -0
  66. data/assets/stylesheets/bootstrap/_responsive-utilities.scss +157 -0
  67. data/assets/stylesheets/bootstrap/_scaffolding.scss +161 -0
  68. data/assets/stylesheets/bootstrap/_tables.scss +233 -0
  69. data/assets/stylesheets/bootstrap/_theme.scss +293 -0
  70. data/assets/stylesheets/bootstrap/_thumbnails.scss +38 -0
  71. data/assets/stylesheets/bootstrap/_tooltip.scss +112 -0
  72. data/assets/stylesheets/bootstrap/_type.scss +299 -0
  73. data/assets/stylesheets/bootstrap/_utilities.scss +55 -0
  74. data/assets/stylesheets/bootstrap/_variables.scss +875 -0
  75. data/assets/stylesheets/bootstrap/_wells.scss +29 -0
  76. data/assets/stylesheets/bootstrap/mixins/_alerts.scss +15 -0
  77. data/assets/stylesheets/bootstrap/mixins/_background-variant.scss +12 -0
  78. data/assets/stylesheets/bootstrap/mixins/_border-radius.scss +18 -0
  79. data/assets/stylesheets/bootstrap/mixins/_buttons.scss +61 -0
  80. data/assets/stylesheets/bootstrap/mixins/_center-block.scss +7 -0
  81. data/assets/stylesheets/bootstrap/mixins/_clearfix.scss +22 -0
  82. data/assets/stylesheets/bootstrap/mixins/_forms.scss +88 -0
  83. data/assets/stylesheets/bootstrap/mixins/_gradients.scss +50 -0
  84. data/assets/stylesheets/bootstrap/mixins/_grid-framework.scss +82 -0
  85. data/assets/stylesheets/bootstrap/mixins/_grid.scss +123 -0
  86. data/assets/stylesheets/bootstrap/mixins/_hide-text.scss +21 -0
  87. data/assets/stylesheets/bootstrap/mixins/_image.scss +28 -0
  88. data/assets/stylesheets/bootstrap/mixins/_labels.scss +12 -0
  89. data/assets/stylesheets/bootstrap/mixins/_list-group.scss +32 -0
  90. data/assets/stylesheets/bootstrap/mixins/_nav-divider.scss +11 -0
  91. data/assets/stylesheets/bootstrap/mixins/_nav-vertical-align.scss +10 -0
  92. data/assets/stylesheets/bootstrap/mixins/_opacity.scss +7 -0
  93. data/assets/stylesheets/bootstrap/mixins/_pagination.scss +24 -0
  94. data/assets/stylesheets/bootstrap/mixins/_panels.scss +24 -0
  95. data/assets/stylesheets/bootstrap/mixins/_progress-bar.scss +10 -0
  96. data/assets/stylesheets/bootstrap/mixins/_reset-text.scss +18 -0
  97. data/assets/stylesheets/bootstrap/mixins/_resize.scss +6 -0
  98. data/assets/stylesheets/bootstrap/mixins/_responsive-visibility.scss +17 -0
  99. data/assets/stylesheets/bootstrap/mixins/_size.scss +10 -0
  100. data/assets/stylesheets/bootstrap/mixins/_tab-focus.scss +9 -0
  101. data/assets/stylesheets/bootstrap/mixins/_table-row.scss +28 -0
  102. data/assets/stylesheets/bootstrap/mixins/_text-emphasis.scss +12 -0
  103. data/assets/stylesheets/bootstrap/mixins/_text-overflow.scss +8 -0
  104. data/assets/stylesheets/bootstrap/mixins/_vendor-prefixes.scss +210 -0
  105. data/bower.json +38 -0
  106. data/composer.json +21 -0
  107. data/entreprise7pro-bootstrap-sass.gemspec +37 -0
  108. data/eyeglass-exports.js +7 -0
  109. data/lib/entreprise7pro-bootstrap-sass/engine.rb +17 -0
  110. data/lib/entreprise7pro-bootstrap-sass/version.rb +4 -0
  111. data/lib/entreprise7pro-bootstrap-sass.rb +91 -0
  112. data/package-lock.json +1011 -0
  113. data/package.json +48 -0
  114. data/sache.json +5 -0
  115. data/tasks/bower.rake +31 -0
  116. data/tasks/converter/char_string_scanner.rb +38 -0
  117. data/tasks/converter/fonts_conversion.rb +16 -0
  118. data/tasks/converter/js_conversion.rb +47 -0
  119. data/tasks/converter/less_conversion.rb +752 -0
  120. data/tasks/converter/logger.rb +57 -0
  121. data/tasks/converter/network.rb +97 -0
  122. data/tasks/converter.rb +80 -0
  123. data/templates/project/_bootstrap-variables.sass +876 -0
  124. data/templates/project/manifest.rb +20 -0
  125. data/templates/project/styles.sass +6 -0
  126. data/test/compilation_test.rb +30 -0
  127. data/test/dummy_node_mincer/apple-touch-icon-144-precomposed.png +0 -0
  128. data/test/dummy_node_mincer/application.css.ejs.scss +6 -0
  129. data/test/dummy_node_mincer/manifest.js +87 -0
  130. data/test/dummy_rails/README.rdoc +3 -0
  131. data/test/dummy_rails/Rakefile +6 -0
  132. data/test/dummy_rails/app/assets/images/.keep +0 -0
  133. data/test/dummy_rails/app/assets/javascripts/application.js +2 -0
  134. data/test/dummy_rails/app/assets/stylesheets/application.sass +2 -0
  135. data/test/dummy_rails/app/controllers/application_controller.rb +5 -0
  136. data/test/dummy_rails/app/controllers/pages_controller.rb +4 -0
  137. data/test/dummy_rails/app/helpers/application_helper.rb +2 -0
  138. data/test/dummy_rails/app/views/layouts/application.html.erb +14 -0
  139. data/test/dummy_rails/app/views/pages/root.html.slim +84 -0
  140. data/test/dummy_rails/config/application.rb +31 -0
  141. data/test/dummy_rails/config/boot.rb +5 -0
  142. data/test/dummy_rails/config/environment.rb +5 -0
  143. data/test/dummy_rails/config/environments/development.rb +23 -0
  144. data/test/dummy_rails/config/environments/production.rb +82 -0
  145. data/test/dummy_rails/config/environments/test.rb +38 -0
  146. data/test/dummy_rails/config/initializers/backtrace_silencers.rb +7 -0
  147. data/test/dummy_rails/config/initializers/filter_parameter_logging.rb +4 -0
  148. data/test/dummy_rails/config/initializers/inflections.rb +16 -0
  149. data/test/dummy_rails/config/initializers/mime_types.rb +5 -0
  150. data/test/dummy_rails/config/initializers/secret_token.rb +18 -0
  151. data/test/dummy_rails/config/initializers/session_store.rb +3 -0
  152. data/test/dummy_rails/config/initializers/wrap_parameters.rb +14 -0
  153. data/test/dummy_rails/config/locales/en.yml +3 -0
  154. data/test/dummy_rails/config/locales/es.yml +3 -0
  155. data/test/dummy_rails/config/routes.rb +3 -0
  156. data/test/dummy_rails/config.ru +4 -0
  157. data/test/dummy_rails/log/.keep +0 -0
  158. data/test/dummy_sass_only/Gemfile +4 -0
  159. data/test/dummy_sass_only/compile.rb +20 -0
  160. data/test/dummy_sass_only/import_all.scss +2 -0
  161. data/test/gemfiles/default.gemfile +3 -0
  162. data/test/node_mincer_test.rb +35 -0
  163. data/test/node_sass_compile_test.sh +9 -0
  164. data/test/pages_test.rb +14 -0
  165. data/test/sass_test.rb +29 -0
  166. data/test/sprockets_rails_test.rb +31 -0
  167. data/test/support/dummy_rails_integration.rb +22 -0
  168. data/test/support/reporting.rb +27 -0
  169. data/test/test_helper.rb +36 -0
  170. data/test/test_helper_rails.rb +6 -0
  171. metadata +467 -0
@@ -0,0 +1,752 @@
1
+ require_relative 'char_string_scanner'
2
+ require 'bootstrap-sass/version'
3
+
4
+ # This is the script used to automatically convert all of entreprise7pro/bootstrap LESS to Sass.
5
+ #
6
+ # Most differences are fixed by regexps and other forms of string substitution.
7
+ # There are Bootstrap-specific workarounds for the lack of parent selectors, recursion, mixin namespaces, extend within @media, etc in Sass 3.2.
8
+ class Converter
9
+ module LessConversion
10
+ # Some regexps for matching bits of SCSS:
11
+ SELECTOR_CHAR = '\[\]$\w\-{}#,.:&>@'
12
+ # 1 selector (the part before the {)
13
+ SELECTOR_RE = /[#{SELECTOR_CHAR}]+[#{SELECTOR_CHAR}\s]*/
14
+ # 1 // comment
15
+ COMMENT_RE = %r((?:^[ \t]*//[^\n]*\n))
16
+ # 1 {, except when part of @{ and #{
17
+ RULE_OPEN_BRACE_RE = /(?<![@#\$])\{/
18
+ # same as the one above, but in reverse (on a reversed string)
19
+ RULE_OPEN_BRACE_RE_REVERSE = /\{(?![@#\$])/
20
+ # match closed brace, except when \w precedes }, or when }[.'"]. a heurestic to exclude } that are not selector body close }
21
+ RULE_CLOSE_BRACE_RE = /(?<!\w)\}(?![.'"])/
22
+ RULE_CLOSE_BRACE_RE_REVERSE = /(?<![.'"])\}(?!\w)/
23
+ # match any brace that opens or closes a properties body
24
+ BRACE_RE = /#{RULE_OPEN_BRACE_RE}|#{RULE_CLOSE_BRACE_RE}/m
25
+ BRACE_RE_REVERSE = /#{RULE_OPEN_BRACE_RE_REVERSE}|#{RULE_CLOSE_BRACE_RE_REVERSE}/m
26
+ # valid characters in mixin definitions
27
+ SCSS_MIXIN_DEF_ARGS_RE = /[\w\-,\s$:#%()]*/
28
+ LESS_MIXIN_DEF_ARGS_RE = /[\w\-,;.\s@:#%()]*/
29
+
30
+ # These mixins will get vararg definitions in SCSS (not supported by LESS):
31
+ NESTED_MIXINS = {'#gradient' => 'gradient'}
32
+
33
+ # These mixins will get vararg definitions in SCSS (not supported by LESS):
34
+ VARARG_MIXINS = %w(
35
+ scale transition transition-duration transition-property transition-transform box-shadow
36
+ )
37
+
38
+ # A list of classes that will be extracted into mixins
39
+ # Only the top-level selectors of form .CLASS { ... } are extracted. CLASS must not be used in any other rule definition.
40
+ # This is a work-around for libsass @extend issues
41
+ CLASSES_TO_MIXINS = %w(
42
+ list-unstyled form-inline
43
+ )
44
+
45
+ # Convert a snippet of bootstrap LESS to Scss
46
+ def convert_less(less)
47
+ less = convert_to_scss(less)
48
+ less = yield(less) if block_given?
49
+ less
50
+ end
51
+
52
+ def shared_mixins
53
+ @shared_mixins ||= begin
54
+ log_status ' Reading shared mixins from mixins.less'
55
+ CLASSES_TO_MIXINS + read_mixins(read_files('less', bootstrap_less_files.grep(/mixins\//)).values.join("\n"),
56
+ nested: NESTED_MIXINS)
57
+ end
58
+ end
59
+
60
+ def process_stylesheet_assets
61
+ log_status 'Processing stylesheets...'
62
+ files = read_files('less', bootstrap_less_files)
63
+ save_to = @save_to[:scss]
64
+
65
+ log_status ' Converting LESS files to Scss:'
66
+ files.each do |name, file|
67
+ log_processing name
68
+ # apply common conversions
69
+ file = convert_less(file)
70
+ file = replace_all file, %r{// stylelint-disable.*?\n+}, '', optional: true
71
+ if name.start_with?('mixins/')
72
+ file = varargify_mixin_definitions(file, *VARARG_MIXINS)
73
+ %w(responsive-(in)?visibility input-size text-emphasis-variant bg-variant).each do |mixin|
74
+ file = parameterize_mixin_parent_selector file, mixin if file =~ /#{mixin}/
75
+ end
76
+ NESTED_MIXINS.each do |sel, name|
77
+ file = flatten_mixins(file, sel, name) if /#{Regexp.escape(sel)}/ =~ file
78
+ end
79
+ file = replace_all file, /(?<=[.-])\$state/, '#{$state}' if file =~ /[.-]\$state/
80
+ end
81
+ case name
82
+ when 'mixins/buttons.less'
83
+ file = replace_all file, /(\.dropdown-toggle)&/, '&\1'
84
+ when 'mixins/list-group.less'
85
+ file = replace_rules(file, ' .list-group-item-') { |rule| extract_nested_rule rule, 'a&' }
86
+ when 'mixins/gradients.less'
87
+ file = replace_ms_filters(file)
88
+ file = deinterpolate_vararg_mixins(file)
89
+ when 'mixins/vendor-prefixes.less'
90
+ # remove second scale mixins as this is handled via vararg in the first one
91
+ file = replace_rules(file, Regexp.escape('@mixin scale($ratioX, $ratioY...)')) { '' }
92
+ when 'mixins/grid-framework.less'
93
+ file = convert_grid_mixins file
94
+ when 'component-animations.less'
95
+ file = extract_nested_rule file, "#{SELECTOR_RE}&\\.in"
96
+ when 'responsive-utilities.less'
97
+ file = apply_mixin_parent_selector file, '\.(?:visible|hidden)'
98
+ when 'variables.less'
99
+ file = insert_default_vars(file)
100
+ file = ['$bootstrap-sass-asset-helper: false !default;', file].join("\n")
101
+ file = replace_all file, %r{(\$icon-font-path): \s*"(.*)" (!default);}, "\n" + unindent(<<-SCSS, 14)
102
+ // [converter] If $bootstrap-sass-asset-helper if used, provide path relative to the assets load path.
103
+ // [converter] This is because some asset helpers, such as Sprockets, do not work with file-relative paths.
104
+ \\1: if($bootstrap-sass-asset-helper, "bootstrap/", "\\2bootstrap/") \\3;
105
+ SCSS
106
+ when 'breadcrumbs.less'
107
+ file = replace_all file, /(.*)(\\00a0)/, unindent(<<-SCSS, 8) + "\\1\#{$nbsp}"
108
+ // [converter] Workaround for https://github.com/sass/libsass/issues/1115
109
+ $nbsp: "\\2";
110
+ SCSS
111
+ when 'close.less'
112
+ # extract .close { button& {...} } rule
113
+ file = extract_nested_rule file, 'button&'
114
+ when 'dropdowns.less'
115
+ file = replace_all file, /@extend \.dropdown-menu-right;/, 'right: 0; left: auto;'
116
+ file = replace_all file, /@extend \.dropdown-menu-left;/, 'left: 0; right: auto;'
117
+ when 'forms.less'
118
+ file = extract_nested_rule file, 'textarea&'
119
+ file = apply_mixin_parent_selector(file, '\.input-(?:sm|lg)')
120
+ file = replace_rules file, /\.form-group-(?:sm|lg)/ do |rule|
121
+ apply_mixin_parent_selector rule, '.form-control'
122
+ end
123
+ when 'navbar.less'
124
+ file = replace_all file, /(\s*)\.navbar-(right|left)\s*\{\s*@extend\s*\.pull-(right|left);\s*/, "\\1.navbar-\\2 {\\1 float: \\2 !important;\\1"
125
+ when 'tables.less'
126
+ file = replace_all file, /(@include\s*table-row-variant\()(\w+)/, "\\1'\\2'"
127
+ when 'thumbnails.less', 'labels.less', 'badges.less', 'buttons.less'
128
+ file = extract_nested_rule file, 'a&'
129
+ when 'glyphicons.less'
130
+ file = replace_rules(file, /\s*@font-face/) { |rule| replace_asset_url rule, :font }
131
+ when 'type.less'
132
+ file = apply_mixin_parent_selector(file, '\.(text|bg)-(success|primary|info|warning|danger)')
133
+ # .bg-primary will not get patched automatically as it includes an additional rule. fudge for now
134
+ file = replace_all(file, " @include bg-variant($brand-primary);\n}", "}\n@include bg-variant('.bg-primary', $brand-primary);")
135
+ end
136
+
137
+ path = File.join save_to, name.sub(/\.less$/, '.scss')
138
+ path = File.join File.dirname(path), '_' + File.basename(path)
139
+ save_file(path, file)
140
+ log_processed File.basename(path)
141
+ end
142
+
143
+ # move bootstrap/_bootstrap.scss to _bootstrap.scss adjusting import paths
144
+ main_from = "#{save_to}/_bootstrap.scss"
145
+ main_to = File.expand_path("#{save_to}/../_bootstrap.scss")
146
+ save_file main_to, File.read(main_from).gsub(/ "/, ' "bootstrap/')
147
+ File.delete(main_from)
148
+
149
+ # generate variables template
150
+ save_file 'templates/project/_bootstrap-variables.sass',
151
+ "// Override Bootstrap variables here (defaults from bootstrap-sass v#{Bootstrap::VERSION}):\n\n" +
152
+ File.read("#{save_to}/_variables.scss").lines[1..-1].join.gsub(/^(?=\$)/, '// ').gsub(/ !default;/, '')
153
+ end
154
+
155
+ def bootstrap_less_files
156
+ @bootstrap_less_files ||= get_paths_by_type('less', /\.less$/)
157
+ end
158
+
159
+ # apply general less to scss conversion
160
+ def convert_to_scss(file)
161
+ # get local mixin names before converting the definitions
162
+ mixins = shared_mixins + read_mixins(file)
163
+ file = replace_vars(file)
164
+ file = replace_mixin_definitions(file)
165
+ file = replace_mixins(file, mixins)
166
+ file = extract_mixins_from_selectors(file, CLASSES_TO_MIXINS.inject({}) { |h, cl| h.update(".#{cl}" => cl) })
167
+ file = replace_spin(file)
168
+ file = replace_fadein(file)
169
+ file = replace_image_urls(file)
170
+ file = replace_escaping(file)
171
+ file = convert_less_ampersand(file)
172
+ file = deinterpolate_vararg_mixins(file)
173
+ file = replace_calculation_semantics(file)
174
+ file = replace_file_imports(file)
175
+ file = wrap_at_groups_with_at_root(file)
176
+ file = replace_division(file)
177
+ file
178
+ end
179
+
180
+ def wrap_at_groups_with_at_root(file)
181
+ replace_rules(file, /@(?:font-face|-ms-viewport)/) { |rule, _pos|
182
+ %Q(@at-root {\n#{indent rule, 2}\n})
183
+ }
184
+ end
185
+
186
+ def replace_division(less)
187
+ re = %r{
188
+ (?<expression>
189
+ (?<callee>[[:alpha:]\.]+)?
190
+ \(
191
+ (?:
192
+ (?>
193
+ (?<dividend>
194
+ [^()/]+
195
+ |
196
+ \([^/]+\)
197
+ )
198
+ \s+
199
+ /
200
+ \s+
201
+ (?<divisor>
202
+ [^()/]+
203
+ |
204
+ \([^/]+\)
205
+ )
206
+ )
207
+ |
208
+ \g<expression>
209
+ )
210
+ \)
211
+ )
212
+ }x
213
+ return less if less !~ re
214
+ "@use \"sass:math\";\n" + less.gsub(re) do
215
+ named_captures = $~.named_captures
216
+ callee = named_captures['callee']
217
+ dividend = named_captures['dividend']
218
+ divisor = named_captures['divisor']
219
+ expression = "math.div(#{dividend}, #{divisor})"
220
+ callee.nil? ? expression : "#{callee}(#{expression})"
221
+ end
222
+ end
223
+
224
+ def sass_fn_exists(fn)
225
+ %Q{(#{fn}("") != unquote('#{fn}("")'))}
226
+ end
227
+
228
+ def replace_asset_url(rule, type)
229
+ replace_all rule, /url\((.*?)\)/, "url(if($bootstrap-sass-asset-helper, twbs-#{type}-path(\\1), \\1))"
230
+ end
231
+
232
+ # convert recursively evaluated selector $list to @for loop
233
+ def mixin_all_grid_columns(css, selector: raise('pass class'), from: 1, to: raise('pass to'))
234
+ mxn_def = css.each_line.first.strip
235
+ # inject local variables as default arguments
236
+ # this is to avoid overwriting outer variables with the same name with Sass <= 3.3
237
+ # see also: https://github.com/twbs/bootstrap-sass/issues/636
238
+ locals = <<-SASS.strip
239
+ $i: #{from}, $list: "#{selector}"
240
+ SASS
241
+ mxn_def.sub!(/(\(?)(\)\s*\{)/) { "#{$1}#{', ' if $1.empty?}#{locals}#{$2}" }
242
+ step_body = (css =~ /\$list \{\n(.*?)\n[ ]*\}/m) && $1
243
+ <<-SASS
244
+ // [converter] This is defined recursively in LESS, but Sass supports real loops
245
+ #{mxn_def}
246
+ @for $i from (#{from} + 1) through #{to} {
247
+ $list: "\#{$list}, #{selector}";
248
+ }
249
+ \#{$list} {
250
+ #{unindent step_body, 2}
251
+ }
252
+ }
253
+ SASS
254
+ end
255
+
256
+ # convert grid mixins LESS when => Sass @if
257
+ def convert_grid_mixins(file)
258
+ file = replace_rules file, /@mixin make-grid-columns/, comments: false do |css, pos|
259
+ mixin_all_grid_columns css, selector: '.col-xs-#{$i}, .col-sm-#{$i}, .col-md-#{$i}, .col-lg-#{$i}', to: '$grid-columns'
260
+ end
261
+ file = replace_rules file, /@mixin float-grid-columns/, comments: false do |css, pos|
262
+ mixin_all_grid_columns css, selector: '.col-#{$class}-#{$i}', to: '$grid-columns'
263
+ end
264
+ file = replace_rules file, /@mixin calc-grid-column/ do |css|
265
+ css = indent css.gsub(/.*when (.*?) {/, '@if \1 {').gsub(/(\$[\w-]+)\s+=\s+(\w+)/, '\1 == \2').gsub(/(?<=-)(\$[a-z]+)/, '#{\1}')
266
+ if css =~ /== width/
267
+ css = "@mixin calc-grid-column($index, $class, $type) {\n#{css}"
268
+ elsif css =~ /== offset/
269
+ css += "\n}"
270
+ end
271
+ css
272
+ end
273
+ file = replace_rules file, /@mixin loop-grid-columns/ do |css|
274
+ unindent <<-SASS, 8
275
+ // [converter] This is defined recursively in LESS, but Sass supports real loops
276
+ @mixin loop-grid-columns($columns, $class, $type) {
277
+ @for $i from 0 through $columns {
278
+ @include calc-grid-column($i, $class, $type);
279
+ }
280
+ }
281
+ SASS
282
+ end
283
+ file
284
+ end
285
+
286
+
287
+ # We need to keep a list of shared mixin names in order to convert the includes correctly
288
+ # Before doing any processing we read shared mixins from a file
289
+ # If a mixin is nested, it gets prefixed in the list (e.g. #gradient > .horizontal to 'gradient-horizontal')
290
+ def read_mixins(mixins_file, nested: {})
291
+ mixins = get_mixin_names(mixins_file, silent: true)
292
+ nested.each do |selector, prefix|
293
+ # we use replace_rules without replacing anything just to use the parsing algorithm
294
+ replace_rules(mixins_file, selector) { |rule|
295
+ mixins += get_mixin_names(unindent(unwrap_rule_block(rule)), silent: true).map { |name| "#{prefix}-#{name}" }
296
+ rule
297
+ }
298
+ end
299
+ mixins.uniq!
300
+ mixins.sort!
301
+ log_file_info "mixins: #{mixins * ', '}" unless mixins.empty?
302
+ mixins
303
+ end
304
+
305
+ def get_mixin_names(file, opts = {})
306
+ names = get_css_selectors(file).join("\n" * 2).scan(/^\.([\w-]+)\(#{LESS_MIXIN_DEF_ARGS_RE}\)(?: when.*?)?[ ]*\{/).map(&:first).uniq.sort
307
+ log_file_info "mixin defs: #{names * ', '}" unless opts[:silent] || names.empty?
308
+ names
309
+ end
310
+
311
+ # margin: a -b
312
+ # LESS: sets 2 values
313
+ # Sass: sets 1 value (a-b)
314
+ # This wraps a and -b so they evaluates to 2 values in Sass
315
+ def replace_calculation_semantics(file)
316
+ # split_prop_val.call('(@navbar-padding-vertical / 2) -@navbar-padding-horizontal')
317
+ # #=> ["(navbar-padding-vertical / 2)", "-navbar-padding-horizontal"]
318
+ split_prop_val = proc { |val|
319
+ s = CharStringScanner.new(val)
320
+ r = []
321
+ buff = ''
322
+ d = 0
323
+ prop_char = %r([\$\w\-/\*\+%!])
324
+ while (token = s.scan_next(/([\)\(]|\s+|#{prop_char}+)/))
325
+ buff << token
326
+ case token
327
+ when '('
328
+ d += 1
329
+ when ')'
330
+ d -= 1
331
+ if d == 0
332
+ r << buff
333
+ buff = ''
334
+ end
335
+ when /\s/
336
+ if d == 0 && !buff.strip.empty?
337
+ r << buff
338
+ buff = ''
339
+ end
340
+ end
341
+ end
342
+ r << buff unless buff.empty?
343
+ r.map(&:strip)
344
+ }
345
+
346
+ replace_rules file do |rule|
347
+ replace_properties rule do |props|
348
+ props.gsub /(?<!\w)([\w-]+):(.*?);/ do |m|
349
+ prop, vals = $1, split_prop_val.call($2)
350
+ next m unless vals.length >= 2 && vals.any? { |v| v =~ /^[\+\-]\$/ }
351
+ transformed = vals.map { |v| v.strip =~ %r(^\(.*\)$) ? v : "(#{v})" }
352
+ log_transform "property #{prop}: #{transformed * ' '}", from: 'wrap_calculation'
353
+ "#{prop}: #{transformed * ' '};"
354
+ end
355
+ end
356
+ end
357
+ end
358
+
359
+ # @import "file.less" to "#{target_path}file;"
360
+ def replace_file_imports(less, target_path = '')
361
+ less.gsub %r([@\$]import ["|']([\w\-/]+).less["|'];),
362
+ %Q(@import "#{target_path}\\1";)
363
+ end
364
+
365
+ def replace_all(file, regex, replacement = nil, optional: false, &block)
366
+ log_transform regex, replacement
367
+ new_file = file.gsub(regex, replacement, &block)
368
+ raise "replace_all #{regex}, #{replacement} NO MATCH" if !optional && file == new_file
369
+ new_file
370
+ end
371
+
372
+ # @mixin a() { tr& { color:white } }
373
+ # to:
374
+ # @mixin a($parent) { tr#{$parent} { color: white } }
375
+ def parameterize_mixin_parent_selector(file, rule_sel)
376
+ log_transform rule_sel
377
+ param = '$parent'
378
+ replace_rules(file, '^\s*@mixin\s*' + rule_sel) do |mxn_css|
379
+ mxn_css.sub! /(?=@mixin)/, "// [converter] $parent hack\n"
380
+ # insert param into mixin def
381
+ mxn_css.sub!(/(@mixin [\w-]+)\(([\$\w\-,\s]*)\)/) { "#{$1}(#{param}#{', ' if $2 && !$2.empty?}#{$2})" }
382
+ # wrap properties in #{$parent} { ... }
383
+ replace_properties(mxn_css) { |props|
384
+ next props if props.strip.empty?
385
+ spacer = ' ' * indent_width(props)
386
+ "#{spacer}\#{#{param}} {\n#{indent(props.sub(/\s+\z/, ''), 2)}\n#{spacer}}"
387
+ }
388
+ # change nested& rules to nested#{$parent}
389
+ replace_rules(mxn_css, /.*&[ ,:]/) { |rule| replace_in_selector rule, /&/, "\#{#{param}}" }
390
+ end
391
+ end
392
+
393
+ # extracts rule immediately after it's parent, and adjust the selector
394
+ # .x { textarea& { ... }}
395
+ # to:
396
+ # .x { ... }
397
+ # textarea.x { ... }
398
+ def extract_nested_rule(file, selector, new_selector = nil)
399
+ matches = []
400
+ # first find the rules, and remove them
401
+ file = replace_rules(file, "\s*#{selector}", comments: true) { |rule, pos, css|
402
+ new_sel = new_selector || "#{get_selector(rule).gsub(/&/, selector_for_pos(css, pos.begin))}"
403
+ matches << [rule, pos, new_sel]
404
+ indent "// [converter] extracted #{get_selector(rule)} to #{new_sel}".tr("\n", ' ').squeeze(' '), indent_width(rule)
405
+ }
406
+ raise "extract_nested_rule: no such selector: #{selector}" if matches.empty?
407
+ # replace rule selector with new_selector
408
+ matches.each do |m|
409
+ m[0].sub! /(#{COMMENT_RE}*)^(\s*).*?(\s*){/m, "\\1\\2#{m[2]}\\3{"
410
+ log_transform selector, m[2]
411
+ end
412
+ replace_substrings_at file,
413
+ matches.map { |_, pos| close_brace_pos(file, pos.begin, 1) + 1 },
414
+ matches.map { |rule, _| "\n\n" + unindent(rule) }
415
+ end
416
+
417
+ # .visible-sm { @include responsive-visibility() }
418
+ # to:
419
+ # @include responsive-visibility('.visible-sm')
420
+ def apply_mixin_parent_selector(file, rule_sel)
421
+ log_transform rule_sel
422
+ replace_rules file, '\s*' + rule_sel, comments: false do |rule, rule_pos, css|
423
+ body = unwrap_rule_block(rule.dup).strip
424
+ next rule unless body =~ /^@include \w+/m || body =~ /^@media/ && body =~ /\{\s*@include/
425
+ rule =~ /(#{COMMENT_RE}*)([#{SELECTOR_CHAR}\s*]+?)#{RULE_OPEN_BRACE_RE}/
426
+ cmt, sel = $1, $2.strip
427
+ # take one up selector chain if this is an &. selector
428
+ if sel.start_with?('&')
429
+ parent_sel = selector_for_pos(css, rule_pos.begin)
430
+ sel = parent_sel + sel[1..-1]
431
+ end
432
+ # unwrap, and replace @include
433
+ unindent unwrap_rule_block(rule).gsub(/(@include [\w-]+)\(?([\$\w\-,\s]*)\)?/) {
434
+ name, args = $1, $2
435
+ sel.gsub(/\s+/, ' ').split(/,\s*/ ).map { |sel_part|
436
+ "#{cmt}#{name}('#{sel_part}'#{', ' if args && !args.empty?}#{args})"
437
+ }.join(";\n")
438
+ }
439
+ end
440
+ end
441
+
442
+ # #gradient > { @mixin horizontal ... }
443
+ # to:
444
+ # @mixin gradient-horizontal
445
+ def flatten_mixins(file, container, prefix)
446
+ log_transform container, prefix
447
+ replace_rules file, Regexp.escape(container) do |mixins_css|
448
+ unindent unwrap_rule_block(mixins_css).gsub(/@mixin\s*([\w-]+)/, "@mixin #{prefix}-\\1")
449
+ end
450
+ end
451
+
452
+ # .btn { ... } -> @mixin btn { ... }; .btn { @include btn }
453
+ def extract_mixins_from_selectors(file, selectors_to_mixins)
454
+ selectors_to_mixins.each do |selector, mixin|
455
+ file = replace_rules file, Regexp.escape(selector), prefix: false do |selector_css|
456
+ log_transform "#{selector} { ... } -> @mixin #{mixin} { ... }; #{selector} { @include #{mixin} } ", from: 'extract_mixins_from_selectors'
457
+ <<-SCSS
458
+ // [converter] extracted from `#{selector}` for libsass compatibility
459
+ @mixin #{mixin} {#{unwrap_rule_block(selector_css)}
460
+ }
461
+ // [converter] extracted as `@mixin #{mixin}` for libsass compatibility
462
+ #{selector} {
463
+ @include #{mixin};
464
+ }
465
+ SCSS
466
+ end
467
+ end
468
+ file
469
+ end
470
+
471
+ # @include and @extend from LESS:
472
+ # .mixin() -> @include mixin()
473
+ # #scope > .mixin() -> @include scope-mixin()
474
+ # &:extend(.mixin all) -> @include mixin()
475
+ def replace_mixins(less, mixin_names)
476
+ mixin_pattern = /(?<=^|\s)((?:[#|\.][\w-]+\s*>\s*)*)\.([\w-]+)\((.*)\)(?!\s\{)/
477
+
478
+ less = less.gsub(mixin_pattern) do |_|
479
+ scope, name, args = $1, $2, $3
480
+ scope = scope.scan(/[\w-]+/).join('-') + '-' unless scope.empty?
481
+ args = "(#{args.tr(';', ',')})" unless args.empty?
482
+ if name && mixin_names.include?("#{scope}#{name}")
483
+ "@include #{scope}#{name}#{args}"
484
+ else
485
+ "@extend .#{scope}#{name}"
486
+ end
487
+ end
488
+
489
+ less.gsub /&:extend\((#{SELECTOR_RE})(?: all)?\)/ do
490
+ selector = $1
491
+ selector =~ /\.([\w-]+)/
492
+ mixin = $1
493
+ if mixin && mixin_names.include?(mixin)
494
+ "@include #{mixin}"
495
+ else
496
+ "@extend #{selector}"
497
+ end
498
+ end
499
+ end
500
+
501
+ # change Microsoft filters to Sass calling convention
502
+ def replace_ms_filters(file)
503
+ log_transform
504
+ file.gsub(
505
+ /filter: e\(%\("progid:DXImageTransform.Microsoft.gradient\(startColorstr='%d', endColorstr='%d', GradientType=(\d)\)", ?argb\(([\-$\w]+)\), ?argb\(([\-$\w]+)\)\)\);/,
506
+ %Q(filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='\#{ie-hex-str(\\2)}', endColorstr='\#{ie-hex-str(\\3)}', GradientType=\\1);)
507
+ )
508
+ end
509
+
510
+ # unwraps topmost rule block
511
+ # #sel { a: b; }
512
+ # to:
513
+ # a: b;
514
+ def unwrap_rule_block(css)
515
+ css[(css =~ RULE_OPEN_BRACE_RE) + 1..-1].sub(/\n?}\s*\z/m, '')
516
+ end
517
+
518
+ def replace_mixin_definitions(less)
519
+ less.gsub(/^(\s*)\.([\w-]+\(.*\))(\s*\{)/) { |match|
520
+ "#{$1}@mixin #{$2.tr(';', ',')}#{$3}".sub(/,\)/, ')')
521
+ }
522
+ end
523
+
524
+ def replace_vars(less)
525
+ less = less.dup
526
+ # skip header comment
527
+ less =~ %r(\A/\*(.*?)\*/)m
528
+ from = $~ ? $~.to_s.length : 0
529
+ less[from..-1] = less[from..-1].
530
+ gsub(/(?!@mixin|@media|@page|@keyframes|@font-face|@-\w)@/, '$').
531
+ # variables that would be ignored by gsub above: e.g. @page-header-border-color
532
+ gsub(/@(page[\w-]+)/, '$\1')
533
+ less
534
+ end
535
+
536
+ def replace_spin(less)
537
+ less.gsub(/(?![\-$@.])spin(?!-)/, 'adjust-hue')
538
+ end
539
+
540
+ def replace_fadein(less)
541
+ less.gsub(/(?![\-$@.])fadein\((.*?),\s*(.*?)%\)/) { "fade_in(#{$1}, #{$2.to_i / 100.0})" }
542
+ end
543
+
544
+ def replace_image_urls(less)
545
+ less.gsub(/background-image: url\("?(.*?)"?\);/) { |s| replace_asset_url s, :image }
546
+ end
547
+
548
+ def replace_escaping(less)
549
+ less = less.gsub(/~"([^"]+)"/, '\1').gsub(/~'([^']+)'/, '\1') # Get rid of ~"" escape
550
+ less.gsub!(/\$\{([^}]+)\}/, '$\1') # Get rid of @{} escape
551
+ # interpolate variables in strings, e.g. url("$file-1x") => url("#{$file-1x}")
552
+ less.gsub!(/"[^"\n]*"/) { |str| str.gsub(/\$[^"\n$.\\]+/, '#{\0}') }
553
+ less.gsub(/(\W)e\(%\("?([^"]*)"?\)\)/, '\1\2') # Get rid of e(%("")) escape
554
+ end
555
+
556
+ def insert_default_vars(scss)
557
+ log_transform
558
+ scss.gsub(/^(\$.+);/, '\1 !default;')
559
+ end
560
+
561
+ # Converts &-
562
+ def convert_less_ampersand(less)
563
+ regx = /^\.badge\s*\{[\s\/\w\(\)]+(&{1}-{1})\w.*?^}$/m
564
+
565
+ tmp = ''
566
+ less.scan(/^(\s*&)(-[\w\[\]]+\s*\{.+})$/) do |ampersand, css|
567
+ tmp << ".badge#{css}\n"
568
+ end
569
+
570
+ less.gsub(regx, tmp)
571
+ end
572
+
573
+ # unindent by n spaces
574
+ def unindent(txt, n = 2)
575
+ txt.gsub /^[ ]{#{n}}/, ''
576
+ end
577
+
578
+ # indent by n spaces
579
+ def indent(txt, n = 2)
580
+ spaces = ' ' * n
581
+ txt.gsub /^/, spaces
582
+ end
583
+
584
+ # get indent length from the first line of txt
585
+ def indent_width(txt)
586
+ txt.match(/\A\s*/).to_s.length
587
+ end
588
+
589
+ # @mixin transition($transition) {
590
+ # to:
591
+ # @mixin transition($transition...) {
592
+ def varargify_mixin_definitions(scss, *mixins)
593
+ scss = scss.dup
594
+ replaced = []
595
+ mixins.each do |mixin|
596
+ if scss.gsub! /(@mixin\s*#{Regexp.quote(mixin)})\((#{SCSS_MIXIN_DEF_ARGS_RE})\)/, '\1(\2...)'
597
+ replaced << mixin
598
+ end
599
+ end
600
+ log_transform *replaced unless replaced.empty?
601
+ scss
602
+ end
603
+
604
+ # @include transition(#{border-color ease-in-out .15s, box-shadow ease-in-out .15s})
605
+ # to
606
+ # @include transition(border-color ease-in-out .15s, box-shadow ease-in-out .15s)
607
+ def deinterpolate_vararg_mixins(scss)
608
+ scss = scss.dup
609
+ VARARG_MIXINS.each do |mixin|
610
+ if scss.gsub! /(@include\s*#{Regexp.quote(mixin)})\(\s*\#\{([^}]+)\}\s*\)/, '\1(\2)'
611
+ log_transform mixin
612
+ end
613
+ end
614
+ scss
615
+ end
616
+
617
+ # get full selector for rule_block
618
+ def get_selector(rule_block)
619
+ sel = /^\s*(#{SELECTOR_RE}?)\s*\{/.match(rule_block) && $1 && $1.strip
620
+ sel.sub /\s*\{\n\s.*/m, ''
621
+ end
622
+
623
+ # replace CSS rule blocks matching rule_prefix with yield(rule_block, rule_pos)
624
+ # will also include immediately preceding comments in rule_block
625
+ #
626
+ # option :comments -- include immediately preceding comments in rule_block
627
+ #
628
+ # replace_rules(".a{ \n .b{} }", '.b') { |rule, pos| ">#{rule}<" } #=> ".a{ \n >.b{}< }"
629
+ def replace_rules(less, selector = SELECTOR_RE, options = {}, &block)
630
+ options = {prefix: true, comments: true}.merge(options || {})
631
+ less = less.dup
632
+ s = CharStringScanner.new(less)
633
+ rule_re = if options[:prefix]
634
+ /(?:#{selector}[#{SELECTOR_CHAR})=(\s]*?#{RULE_OPEN_BRACE_RE})/
635
+ else
636
+ /#{selector}[\s]*#{RULE_OPEN_BRACE_RE}/
637
+ end
638
+ rule_start_re = if options[:comments]
639
+ /(?:#{COMMENT_RE}*)^#{rule_re}/
640
+ else
641
+ /^#{rule_re}/
642
+ end
643
+
644
+ positions = []
645
+ while (rule_start = s.scan_next(rule_start_re))
646
+ pos = s.pos
647
+ positions << (pos - rule_start.length..close_brace_pos(less, pos - 1))
648
+ end
649
+ replace_substrings_at(less, positions, &block)
650
+ less
651
+ end
652
+
653
+ # Get a all top-level selectors (with {)
654
+ def get_css_selectors(css, opts = {})
655
+ s = CharStringScanner.new(css)
656
+ selectors = []
657
+ while s.scan_next(RULE_OPEN_BRACE_RE)
658
+ brace_pos = s.pos
659
+ def_pos = css_def_pos(css, brace_pos+1, -1)
660
+ sel = css[def_pos.begin..brace_pos - 1].dup
661
+ sel.strip! if opts[:strip]
662
+ selectors << sel
663
+ sel.dup.strip
664
+ s.pos = close_brace_pos(css, brace_pos, 1) + 1
665
+ end
666
+ selectors
667
+ end
668
+
669
+ # replace in the top-level selector
670
+ # replace_in_selector('a {a: {a: a} } a {}', /a/, 'b') => 'b {a: {a: a} } b {}'
671
+ def replace_in_selector(css, pattern, sub)
672
+ # scan for selector positions in css
673
+ s = CharStringScanner.new(css)
674
+ prev_pos = 0
675
+ sel_pos = []
676
+ while (brace = s.scan_next(RULE_OPEN_BRACE_RE))
677
+ pos = s.pos
678
+ sel_pos << (prev_pos .. pos - 1)
679
+ s.pos = close_brace_pos(css, s.pos - 1) + 1
680
+ prev_pos = pos
681
+ end
682
+ replace_substrings_at(css, sel_pos) { |s| s.gsub(pattern, sub) }
683
+ end
684
+
685
+
686
+ # replace first level properties in the css with yields
687
+ # replace_properties("a { color: white }") { |props| props.gsub 'white', 'red' }
688
+ def replace_properties(css, &block)
689
+ s = CharStringScanner.new(css)
690
+ s.skip_until /#{RULE_OPEN_BRACE_RE}\n?/
691
+ from = s.pos
692
+ m = s.scan_next(/\s*#{SELECTOR_RE}#{RULE_OPEN_BRACE_RE}/) || s.scan_next(/\s*#{RULE_CLOSE_BRACE_RE}/)
693
+ to = s.pos - m.length - 1
694
+ replace_substrings_at css, [(from .. to)], &block
695
+ end
696
+
697
+
698
+ # immediate selector of css at pos
699
+ def selector_for_pos(css, pos, depth = -1)
700
+ css[css_def_pos(css, pos, depth)].dup.strip
701
+ end
702
+
703
+ # get the pos of css def at pos (search backwards)
704
+ def css_def_pos(css, pos, depth = -1)
705
+ to = open_brace_pos(css, pos, depth)
706
+ prev_def = to - (css[0..to].reverse.index(RULE_CLOSE_BRACE_RE_REVERSE) || to) + 1
707
+ from = prev_def + 1 + (css[prev_def + 1..-1] =~ %r(^\s*[^\s/]))
708
+ (from..to - 1)
709
+ end
710
+
711
+ # next matching brace for brace at from
712
+ def close_brace_pos(css, from, depth = 0)
713
+ s = CharStringScanner.new(css[from..-1])
714
+ while (b = s.scan_next(BRACE_RE))
715
+ depth += (b == '}' ? -1 : +1)
716
+ break if depth.zero?
717
+ end
718
+ raise "match not found for {" unless depth.zero?
719
+ from + s.pos - 1
720
+ end
721
+
722
+ # opening brace position from +from+ (search backwards)
723
+ def open_brace_pos(css, from, depth = 0)
724
+ s = CharStringScanner.new(css[0..from].reverse)
725
+ while (b = s.scan_next(BRACE_RE_REVERSE))
726
+ depth += (b == '{' ? +1 : -1)
727
+ break if depth.zero?
728
+ end
729
+ raise "matching { brace not found" unless depth.zero?
730
+ from - s.pos + 1
731
+ end
732
+
733
+ # insert substitutions into text at positions (Range or Integer)
734
+ # substitutions can be passed as array or as yields from the &block called with |substring, position, text|
735
+ # position is a range (begin..end)
736
+ def replace_substrings_at(text, positions, replacements = nil, &block)
737
+ offset = 0
738
+ positions.each_with_index do |p, i|
739
+ p = (p...p) if p.is_a?(Integer)
740
+ from = p.begin + offset
741
+ to = p.end + offset
742
+ p = p.exclude_end? ? (from...to) : (from..to)
743
+ # block returns the substitution, e.g.: { |text, pos| text[pos].upcase }
744
+ r = replacements ? replacements[i] : block.call(text[p], p, text)
745
+ text[p] = r
746
+ # add the change in length to offset
747
+ offset += r.size - (p.end - p.begin + (p.exclude_end? ? 0 : 1))
748
+ end
749
+ text
750
+ end
751
+ end
752
+ end