deadfire 0.1.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.
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "deadfire"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/bin/test ADDED
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+
3
+ if [[ -z $1 ]]; then
4
+ bundle exec rake
5
+ else
6
+ bundle exec rake TEST="$1"
7
+ fi
data/changelog.md ADDED
@@ -0,0 +1,5 @@
1
+ ## Changelog
2
+
3
+ ### 0.1.0 (17 October 2022)
4
+
5
+ Initial release
data/deadfire.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ require_relative 'lib/deadfire/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "deadfire"
5
+ spec.version = Deadfire::VERSION
6
+ spec.authors = ["Haroon Ahmed"]
7
+ spec.email = ["haroon.ahmed25@gmail.com"]
8
+
9
+ spec.summary = "Deadfire - lightweight css preprocessor"
10
+ spec.homepage = "https://github.com/hahmed/deadfire"
11
+ spec.license = "MIT"
12
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.7")
13
+
14
+ spec.metadata["homepage_uri"] = spec.homepage
15
+ spec.metadata["source_code_uri"] = "https://github.com/hahmed/deadfire"
16
+ spec.metadata["changelog_uri"] = "https://github.com/hahmed/deadfire/changelog.md"
17
+
18
+ # Specify which files should be added to the gem when it is released.
19
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
21
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
22
+ end
23
+ spec.bindir = "exe"
24
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
+ spec.require_paths = ["lib"]
26
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deadfire
4
+ class Configuration
5
+ attr_reader :directories, :root_path, :keep_comments
6
+
7
+ def initialize
8
+ @directories = []
9
+ @root_path = ""
10
+ @keep_comments = true
11
+ end
12
+
13
+ def root_path=(value)
14
+ unless Dir.exist?(value)
15
+ raise DirectoryNotFoundError.new("Root not found #{value}")
16
+ end
17
+ @root_path = value
18
+ end
19
+
20
+ def keep_comments=(value)
21
+ @keep_comments = value
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ require "stringio"
3
+
4
+ module Deadfire
5
+ class CssBuffer < StringIO
6
+ def initialize(content)
7
+ super(content)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deadfire
4
+ class DirectoryNotFoundError < StandardError; end
5
+ class Error < StandardError; end
6
+
7
+ class DuplicateImportException < StandardError
8
+ def initialize(filename = "", lineno = "")
9
+ msg = if filename
10
+ "Duplicate import found: `#{filename}` line: #{lineno}"
11
+ else
12
+ "Duplicate import."
13
+ end
14
+
15
+ super(msg)
16
+ end
17
+ end
18
+
19
+ class ImportException < StandardError
20
+ def initialize(filename = "", lineno = "")
21
+ msg = if filename
22
+ "Error importing file: `#{filename}` line: #{lineno}"
23
+ else
24
+ "Error importing file."
25
+ end
26
+
27
+ super(msg)
28
+ end
29
+ end
30
+
31
+ class EarlyApplyException < StandardError
32
+ def initialize(input = "", lineno = "")
33
+ msg = if input
34
+ "Error with input: `#{input}` line: #{lineno}"
35
+ else
36
+ "Apply called too early in css. There are no mixins defined."
37
+ end
38
+
39
+ super(msg)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,380 @@
1
+ # frozen_string_literal: true
2
+ module Deadfire
3
+ class Parser
4
+ singleton_class.attr_accessor :cached_mixins
5
+ self.cached_mixins = Hash.new { |h, k| h[k] = {} }
6
+
7
+ singleton_class.attr_accessor :import_path_cache
8
+ self.import_path_cache = []
9
+
10
+ ROOT_SELECTOR = ":root {"
11
+ OPENING_SELECTOR_PATTERN = /\s*\{/
12
+ CLOSING_SELECTOR_PATTERN = /\s*\}/
13
+ NEST_SELECTOR = "&"
14
+ START_BLOCK_CHAR = "{"
15
+ END_BLOCK_CHAR = "}"
16
+ OPENING_SELECTOR_PATTERN_OTHER = /\..*\{/
17
+ IMPORT_SELECTOR = "@import"
18
+ CSS_FILE_EXTENSION = ".css"
19
+ APPLY_SELECTOR = "@apply"
20
+ NEWLINE = "\n"
21
+
22
+ def self.parse(content, options = {})
23
+ new(content, options).parse
24
+ end
25
+
26
+ attr_reader :output
27
+
28
+ def initialize(content, options = {})
29
+ @content = content
30
+ @filename = options[:filename]
31
+ @output = []
32
+ @imports = []
33
+ end
34
+
35
+ def buffer
36
+ @buffer ||= CssBuffer.new(@content)
37
+ end
38
+
39
+ class Line
40
+ attr_accessor :content, :line_number
41
+
42
+ def initialize(content, line_number)
43
+ @content = content
44
+ @line_number = line_number
45
+ end
46
+
47
+ def to_s
48
+ content
49
+ end
50
+ end
51
+
52
+ class Root < Line
53
+ def initialize(content, lineno, buffer)
54
+ super(content, lineno)
55
+ @end_tag = false
56
+ @output_current_line = true
57
+ @output = []
58
+ @buffer = buffer
59
+ end
60
+
61
+ def parse
62
+ line = @content
63
+ if line.include? ROOT_SELECTOR
64
+ @output << Line.new(line, @line_number)
65
+ end
66
+
67
+ while !@end_tag && line = @buffer.gets
68
+ if line =~ OPENING_SELECTOR_PATTERN
69
+ @output_current_line = false
70
+ name = extract_mixin_name(line)
71
+ properties = extract_properties_from_mixin(@buffer, line)
72
+ Parser.cached_mixins[name] = properties
73
+ elsif line =~ CLOSING_SELECTOR_PATTERN
74
+ @end_tag = true
75
+ end
76
+
77
+ @output << Line.new(line, @buffer.lineno) if @output_current_line
78
+ @output_current_line = true
79
+ end
80
+
81
+ to_s
82
+ end
83
+
84
+ def to_s
85
+ return "" if @output.size <= 1
86
+
87
+ @output.map(&:to_s)
88
+ end
89
+
90
+ private
91
+
92
+ def extract_mixin_name(line)
93
+ line.tr("{", "").tr(".", "").tr(":", "").strip
94
+ end
95
+
96
+ def extract_properties_from_mixin(buffer, line)
97
+ properties = {}
98
+ line = buffer.gets # skip opening {
99
+ while line !~ CLOSING_SELECTOR_PATTERN && !buffer.eof?
100
+ name, value = extract_name_and_values(line)
101
+ properties[name] = value
102
+ line = buffer.gets
103
+ end
104
+ properties
105
+ end
106
+
107
+ def extract_name_and_values(line)
108
+ name, value = line.split(":")
109
+ value = value.gsub(";", "")
110
+ [name, value].map(&:strip)
111
+ end
112
+ end
113
+
114
+ class Import < Line
115
+ attr_accessor :import_path
116
+
117
+ def initialize(content, lineno)
118
+ super
119
+ @import_path = self.class.resolve_import_path(content, lineno)
120
+ end
121
+
122
+ def parse
123
+ Parser.new(File.read(import_path), filename: import_path).parse
124
+ end
125
+
126
+ class << self
127
+ def resolve_import_path(line, lineno = 0)
128
+ path = normalize_import_path(line)
129
+ unless path.end_with?(Parser::CSS_FILE_EXTENSION)
130
+ path += Parser::CSS_FILE_EXTENSION
131
+ end
132
+ import_path = File.join(Deadfire.configuration.root_path, path)
133
+
134
+ unless File.exist?(import_path)
135
+ raise Deadfire::ImportException.new(import_path, lineno)
136
+ end
137
+
138
+ import_path
139
+ end
140
+
141
+ def normalize_import_path(line)
142
+ path = line.split.last
143
+ path.gsub!("\"", "")
144
+ path.gsub!("\'", "")
145
+ path.gsub!(";", "")
146
+ path
147
+ end
148
+ end
149
+ end
150
+
151
+ class Apply < Line
152
+ def initialize(...)
153
+ super
154
+ @current_line = @content.dup
155
+ @space = " "
156
+ @space_counter = 0
157
+ @import_start_tag = "@"
158
+ @output = []
159
+ end
160
+
161
+ def parse
162
+ raise Deadfire::EarlyApplyException.new(@content, @lineno) if Parser.cached_mixins.empty?
163
+
164
+ @current_line.each_char do |char|
165
+ break if char == @import_start_tag
166
+ @space_counter += 1
167
+ end
168
+
169
+ @current_line.split(" ").each do |css|
170
+ next if css.include?(APPLY_SELECTOR)
171
+ css.gsub!(";", "")
172
+
173
+ fetch_cached_mixin(css).each_pair do |key, value|
174
+ @output << "#{@space * @space_counter}#{key}: #{value};"
175
+ end
176
+ end
177
+ @output
178
+ end
179
+
180
+ private
181
+
182
+ # find css class key/val from hash, otherwise throw because the mixin is not defined
183
+ def fetch_cached_mixin(key)
184
+ raise Deadfire::EarlyApplyException.new(key, @lineno) unless Parser.cached_mixins.include?(key)
185
+
186
+ Parser.cached_mixins[key]
187
+ end
188
+ end
189
+
190
+ class Nesting < Line
191
+ attr_accessor :block_names
192
+
193
+ def initialize(content, lineno, buffer, output)
194
+ super(content, lineno)
195
+ @buffer = buffer
196
+ @output = output
197
+ @block_names = []
198
+ @nested_level = 0
199
+ end
200
+
201
+ def parse
202
+ line = content.dup.strip
203
+ @block_names << find_block_name(@output, @lineno)
204
+ tmp = []
205
+
206
+ add_end_block_when_no_end_block_on_prev_line(arr: tmp)
207
+ while @nested_level > 0 || !@buffer.eof?
208
+ spaces = calculate_spaces_to_add(line)
209
+ if line.start_with?(NEST_SELECTOR)
210
+ add_end_block_when_no_end_block_on_prev_line(arr: tmp) if @nested_level > 0
211
+ add_selector_to_block_name(line)
212
+ @nested_level += 1
213
+ tmp << rewrite_line(spaces, line, @block_names[0...-1].join(" "))
214
+ remove_last_block_name_entry if line.end_with?(END_BLOCK_CHAR)
215
+ else
216
+ remove_last_block_name_entry if line.end_with?(END_BLOCK_CHAR)
217
+ tmp << "#{spaces}#{line.lstrip}"
218
+ end
219
+
220
+ line = @buffer.gets
221
+
222
+ if line.nil? || @buffer.eof? || line.empty?
223
+ break
224
+ else
225
+ line.strip!
226
+ end
227
+ end
228
+
229
+ tmp.pop if tmp[-1] == END_BLOCK_CHAR
230
+ tmp.join("\n")
231
+ end
232
+
233
+ private
234
+
235
+ def remove_last_block_name_entry
236
+ @nested_level -= 1
237
+ @block_names.pop
238
+ end
239
+
240
+ def add_selector_to_block_name(line)
241
+ line = extract_selector(line)
242
+ line = line_without_nested_block(line)
243
+ @block_names << line unless @block_names.include?(line)
244
+ end
245
+
246
+ def add_end_block_when_no_end_block_on_prev_line(arr: @output)
247
+ unless arr[-1]&.strip&.end_with?("}")
248
+ arr << "}"
249
+ end
250
+ end
251
+
252
+ def calculate_spaces_to_add(line)
253
+ unless line =~ OPENING_SELECTOR_PATTERN || line =~ CLOSING_SELECTOR_PATTERN
254
+ " "
255
+ else
256
+ ""
257
+ end
258
+ end
259
+
260
+ def extract_selector(line)
261
+ line.split(START_BLOCK_CHAR).first.strip
262
+ end
263
+
264
+ def line_without_nested_block(line)
265
+ line.split(NEST_SELECTOR).last.strip
266
+ end
267
+
268
+ def rewrite_line(spaces, line, selector)
269
+ case number_of_selectors_in(line)
270
+ when 0
271
+ line
272
+ when 1
273
+ "#{spaces}#{line.lstrip.gsub("&", selector)}"
274
+ else
275
+ line.strip.each_char.map do |s|
276
+ if s == NEST_SELECTOR
277
+ selector
278
+ else
279
+ s
280
+ end
281
+ end.join
282
+ end
283
+ end
284
+
285
+ def number_of_selectors_in(line)
286
+ line.split.count do |s|
287
+ # break if s == "{" # early exit, no need to read every char
288
+ s.start_with?(NEST_SELECTOR)
289
+ end
290
+ end
291
+
292
+ def find_block_name(output, lineno = nil)
293
+ lineno = output.size unless lineno
294
+ if lineno < 0
295
+ raise "Cannot find block name"
296
+ end
297
+
298
+ line = output[lineno]
299
+
300
+ if line.to_s =~ OPENING_SELECTOR_PATTERN
301
+ extract_selector(line)
302
+ else
303
+ find_block_name(output, lineno - 1)
304
+ end
305
+ end
306
+ end
307
+
308
+ def parse
309
+ while ! buffer.eof?
310
+ process_line(buffer.readline)
311
+ end
312
+
313
+ @output << NEWLINE
314
+
315
+ @output.join
316
+ end
317
+
318
+ private
319
+
320
+ # this method returns void, and modifies the output array directly
321
+ def process_line(line)
322
+ if line.strip.start_with?("/*")
323
+ handle_comment(line)
324
+ elsif line.strip.start_with?("@import")
325
+ handle_import(line)
326
+ elsif line.strip.start_with?(":root {")
327
+ handle_mixins(line)
328
+ elsif line.strip.start_with?("@apply") # or line.include?("@apply")
329
+ handle_apply(line)
330
+ elsif line.strip.start_with?("&")
331
+ handle_nestings(line)
332
+ else
333
+ @output << line
334
+ end
335
+ end
336
+
337
+ def keep_comments?
338
+ Deadfire.configuration.keep_comments
339
+ end
340
+
341
+ def handle_comment(line)
342
+ @output << Line.new(line, buffer.lineno) if keep_comments?
343
+
344
+ while ! line.include?("*/") && ! buffer.eof?
345
+ line = buffer.gets
346
+ @output << Line.new(line, buffer.lineno) if keep_comments?
347
+ end
348
+ end
349
+
350
+ def handle_import(line)
351
+ import = Import.new(line, buffer.lineno)
352
+
353
+ if self.class.import_path_cache.include?(import.import_path)
354
+ raise DuplicateImportException.new(import.import_path, buffer.lineno)
355
+ end
356
+
357
+ self.class.import_path_cache << import.import_path
358
+
359
+ # TODO:
360
+ # - decide on how many levels of imports we want to allow
361
+ # - make async??
362
+ @output << import.parse
363
+ end
364
+
365
+ def handle_apply(line)
366
+ @apply = Apply.new(line, buffer.lineno)
367
+ @output << @apply.parse.join(NEWLINE)
368
+ end
369
+
370
+ def handle_mixins(line)
371
+ @root = Root.new(line, buffer.lineno, buffer)
372
+ @output << @root.parse
373
+ end
374
+
375
+ def handle_nestings(line)
376
+ nesting = Nesting.new(line, buffer.lineno, buffer, @output)
377
+ @output << nesting.parse
378
+ end
379
+ end
380
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deadfire::Transformers
4
+ class Transformer
5
+ def name
6
+ self.class.name
7
+ end
8
+
9
+ def matches?(line)
10
+ false
11
+ end
12
+
13
+ def transform(line, buffer, output); end
14
+
15
+ def reset; end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deadfire
4
+ VERSION = "0.1.0"
5
+ end
data/lib/deadfire.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "deadfire/css_buffer"
4
+ require_relative "deadfire/configuration"
5
+ require_relative "deadfire/errors"
6
+ require_relative "deadfire/parser"
7
+ require_relative "deadfire/transformers/transformer"
8
+ require_relative "deadfire/version"
9
+
10
+ module Deadfire
11
+ class << self
12
+ def configuration
13
+ @configuration ||= Configuration.new
14
+ end
15
+
16
+ def reset
17
+ @configuration = Configuration.new
18
+ end
19
+
20
+ def configure
21
+ yield(@configuration)
22
+ end
23
+
24
+ def parse(content, options = {})
25
+ Parser.parse(content, options)
26
+ end
27
+ end
28
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: deadfire
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Haroon Ahmed
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-10-17 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - haroon.ahmed25@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".github/workflows/ci.yml"
21
+ - ".gitignore"
22
+ - CODE_OF_CONDUCT.md
23
+ - Gemfile
24
+ - Gemfile.lock
25
+ - LICENSE.txt
26
+ - README.md
27
+ - Rakefile
28
+ - benchmarks/basic_benchmark.rb
29
+ - benchmarks/input.scss
30
+ - benchmarks/output.css
31
+ - benchmarks/output.css.map
32
+ - benchmarks/profilng.rb
33
+ - benchmarks/tailwind.css
34
+ - bin/console
35
+ - bin/setup
36
+ - bin/test
37
+ - changelog.md
38
+ - deadfire.gemspec
39
+ - lib/deadfire.rb
40
+ - lib/deadfire/configuration.rb
41
+ - lib/deadfire/css_buffer.rb
42
+ - lib/deadfire/errors.rb
43
+ - lib/deadfire/parser.rb
44
+ - lib/deadfire/transformers/transformer.rb
45
+ - lib/deadfire/version.rb
46
+ homepage: https://github.com/hahmed/deadfire
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://github.com/hahmed/deadfire
51
+ source_code_uri: https://github.com/hahmed/deadfire
52
+ changelog_uri: https://github.com/hahmed/deadfire/changelog.md
53
+ post_install_message:
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '2.7'
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.3.7
69
+ signing_key:
70
+ specification_version: 4
71
+ summary: Deadfire - lightweight css preprocessor
72
+ test_files: []