windsock 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/CODE_OF_CONDUCT.md +85 -0
- data/CONTRIBUTING.md +30 -0
- data/LICENSE.txt +21 -0
- data/README.md +200 -0
- data/Rakefile +12 -0
- data/SECURITY.md +14 -0
- data/lib/windsock/color.rb +441 -0
- data/lib/windsock/helper.rb +56 -0
- data/lib/windsock/parser.rb +245 -0
- data/lib/windsock/railtie.rb +29 -0
- data/lib/windsock/scale.rb +189 -0
- data/lib/windsock/tailwind/LICENSE +21 -0
- data/lib/windsock/tailwind/theme.css +510 -0
- data/lib/windsock/theme.rb +220 -0
- data/lib/windsock/version.rb +5 -0
- data/lib/windsock.rb +67 -0
- metadata +62 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Windsock
|
|
4
|
+
# Extracts theme variables from Tailwind v4 CSS.
|
|
5
|
+
#
|
|
6
|
+
# Walks the stylesheet looking for top-level `@theme { ... }` blocks and
|
|
7
|
+
# `@import` statements. Everything else (`@layer`, `@utility`, plain rules)
|
|
8
|
+
# is skipped. `@import "tailwindcss"` pulls in the bundled default theme.
|
|
9
|
+
#
|
|
10
|
+
# Precedence follows Tailwind: values from `@theme default` blocks never
|
|
11
|
+
# override values set in a regular `@theme` block, regardless of order.
|
|
12
|
+
# `--color-*: initial` clears a namespace; `--*: initial` clears everything.
|
|
13
|
+
class Parser
|
|
14
|
+
TAILWIND_IMPORTS = %w[tailwindcss tailwindcss/theme tailwindcss/theme.css].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :vars, :files
|
|
17
|
+
|
|
18
|
+
def self.parse(css, base_dir: nil)
|
|
19
|
+
new.parse(css, base_dir: base_dir)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.parse_file(path)
|
|
23
|
+
new.parse_file(path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@vars = {}
|
|
28
|
+
@owned = {} # keys set by a non-default @theme block
|
|
29
|
+
@files = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parse_file(path)
|
|
33
|
+
path = File.expand_path(path)
|
|
34
|
+
return self if files.include?(path)
|
|
35
|
+
|
|
36
|
+
files << path
|
|
37
|
+
parse(File.read(path), base_dir: File.dirname(path))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parse(css, base_dir: nil)
|
|
41
|
+
Scanner.new(strip_comments(css)).each_rule do |name, prelude, body|
|
|
42
|
+
case name
|
|
43
|
+
when "import" then import(prelude, base_dir)
|
|
44
|
+
when "theme" then theme_block(prelude, body)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def strip_comments(css)
|
|
53
|
+
css.gsub(%r{/\*.*?\*/}m, "")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def import(prelude, base_dir)
|
|
57
|
+
spec = prelude[/\A\s*(?:url\()?\s*["']([^"']+)["']/, 1]
|
|
58
|
+
return unless spec
|
|
59
|
+
|
|
60
|
+
if TAILWIND_IMPORTS.include?(spec)
|
|
61
|
+
parse_file(Windsock.default_theme_path)
|
|
62
|
+
elsif spec.start_with?("./", "../", "/")
|
|
63
|
+
parse_file(resolve_import(spec, base_dir))
|
|
64
|
+
end
|
|
65
|
+
# Bare specifiers ("tailwindcss/utilities", "tw-animate-css", ...) are ignored.
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def resolve_import(spec, base_dir)
|
|
69
|
+
base = spec.start_with?("/") ? spec : File.expand_path(spec, base_dir || Dir.pwd)
|
|
70
|
+
candidates = [base, "#{base}.css", File.join(base, "index.css")]
|
|
71
|
+
candidates.find { |path| File.file?(path) } ||
|
|
72
|
+
raise(ImportError, "cannot resolve @import #{spec.inspect} from #{base_dir || Dir.pwd}")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def theme_block(prelude, body)
|
|
76
|
+
default = prelude.split.include?("default")
|
|
77
|
+
Scanner.new(body).each_declaration do |name, value|
|
|
78
|
+
next unless name.start_with?("--")
|
|
79
|
+
|
|
80
|
+
key = name[2..]
|
|
81
|
+
value = value.gsub(/\s*\n\s*/, " ")
|
|
82
|
+
if value == "initial"
|
|
83
|
+
reset(key)
|
|
84
|
+
else
|
|
85
|
+
assign(key, value, default)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def assign(key, value, default)
|
|
91
|
+
return if default && @owned[key]
|
|
92
|
+
|
|
93
|
+
@vars[key] = value
|
|
94
|
+
@owned[key] = true unless default
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def reset(key)
|
|
98
|
+
if key == "*"
|
|
99
|
+
@vars.clear
|
|
100
|
+
@owned.clear
|
|
101
|
+
elsif key.end_with?("-*")
|
|
102
|
+
prefix = key[0..-2]
|
|
103
|
+
@vars.delete_if { |k, _| k.start_with?(prefix) }
|
|
104
|
+
@owned.delete_if { |k, _| k.start_with?(prefix) }
|
|
105
|
+
else
|
|
106
|
+
@vars.delete(key)
|
|
107
|
+
@owned.delete(key)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Minimal CSS tokenizer: finds at-rules and declarations at one nesting
|
|
112
|
+
# level, honouring strings, parentheses and braces.
|
|
113
|
+
class Scanner
|
|
114
|
+
def initialize(css)
|
|
115
|
+
@css = css
|
|
116
|
+
@pos = 0
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Yields [name, prelude, body] for each at-rule. Body is nil for
|
|
120
|
+
# statement at-rules (`@import ...;`). Other rules are skipped.
|
|
121
|
+
def each_rule
|
|
122
|
+
until eof?
|
|
123
|
+
skip_whitespace
|
|
124
|
+
break if eof?
|
|
125
|
+
|
|
126
|
+
if peek == "@"
|
|
127
|
+
yield(*read_at_rule)
|
|
128
|
+
else
|
|
129
|
+
skip_rule
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Yields [name, value] for each declaration. Nested blocks (e.g.
|
|
135
|
+
# `@keyframes` inside `@theme`) are skipped.
|
|
136
|
+
def each_declaration
|
|
137
|
+
until eof?
|
|
138
|
+
skip_whitespace
|
|
139
|
+
break if eof?
|
|
140
|
+
|
|
141
|
+
if peek == "@"
|
|
142
|
+
read_at_rule
|
|
143
|
+
elsif peek == ";"
|
|
144
|
+
advance
|
|
145
|
+
else
|
|
146
|
+
text = read_until(";", "{", "}")
|
|
147
|
+
if peek == "{"
|
|
148
|
+
skip_block
|
|
149
|
+
else
|
|
150
|
+
advance if peek == ";"
|
|
151
|
+
name, value = text.split(":", 2)
|
|
152
|
+
yield(name.strip, value.strip) if value
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def eof? = @pos >= @css.length
|
|
161
|
+
def peek = @css[@pos]
|
|
162
|
+
def advance = (@pos += 1)
|
|
163
|
+
|
|
164
|
+
def skip_whitespace
|
|
165
|
+
@pos += 1 while !eof? && peek.match?(/\s/)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def read_at_rule
|
|
169
|
+
advance # "@"
|
|
170
|
+
name = @css[@pos..][/\A[\w-]+/] || ""
|
|
171
|
+
@pos += name.length
|
|
172
|
+
prelude = read_until(";", "{", "}").strip
|
|
173
|
+
|
|
174
|
+
case peek
|
|
175
|
+
when "{"
|
|
176
|
+
[name, prelude, read_block]
|
|
177
|
+
else
|
|
178
|
+
advance if peek == ";"
|
|
179
|
+
[name, prelude, nil]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def skip_rule
|
|
184
|
+
read_until(";", "{")
|
|
185
|
+
peek == "{" ? skip_block : advance
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def skip_block
|
|
189
|
+
read_block
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Assumes peek == "{". Returns the inner text and moves past "}".
|
|
193
|
+
def read_block
|
|
194
|
+
advance
|
|
195
|
+
start = @pos
|
|
196
|
+
depth = 1
|
|
197
|
+
until eof?
|
|
198
|
+
char = peek
|
|
199
|
+
if %w[" '].include?(char)
|
|
200
|
+
skip_string
|
|
201
|
+
next
|
|
202
|
+
end
|
|
203
|
+
depth += 1 if char == "{"
|
|
204
|
+
depth -= 1 if char == "}"
|
|
205
|
+
advance
|
|
206
|
+
return @css[start...(@pos - 1)] if depth.zero?
|
|
207
|
+
end
|
|
208
|
+
@css[start..]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Reads until one of the stop chars at paren depth zero, outside strings.
|
|
212
|
+
def read_until(*stops)
|
|
213
|
+
start = @pos
|
|
214
|
+
depth = 0
|
|
215
|
+
until eof?
|
|
216
|
+
char = peek
|
|
217
|
+
if %w[" '].include?(char)
|
|
218
|
+
skip_string
|
|
219
|
+
next
|
|
220
|
+
end
|
|
221
|
+
depth += 1 if char == "("
|
|
222
|
+
depth -= 1 if char == ")" && depth.positive?
|
|
223
|
+
break if depth.zero? && stops.include?(char)
|
|
224
|
+
|
|
225
|
+
advance
|
|
226
|
+
end
|
|
227
|
+
@css[start...@pos]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def skip_string
|
|
231
|
+
quote = peek
|
|
232
|
+
advance
|
|
233
|
+
until eof?
|
|
234
|
+
char = peek
|
|
235
|
+
advance
|
|
236
|
+
if char == "\\"
|
|
237
|
+
advance
|
|
238
|
+
elsif char == quote
|
|
239
|
+
break
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Windsock
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
config.windsock = ActiveSupport::OrderedOptions.new
|
|
6
|
+
|
|
7
|
+
initializer "windsock.path" do |app|
|
|
8
|
+
Windsock.path ||= app.config.windsock.path || app.root.join("app/assets/tailwind/application.css")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "windsock.helper" do
|
|
12
|
+
ActiveSupport.on_load(:action_view) { include Windsock::Helper }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Re-parse when any CSS under the stylesheet's directory changes.
|
|
16
|
+
initializer "windsock.reloader" do |app|
|
|
17
|
+
config = app.config
|
|
18
|
+
reloading = config.respond_to?(:reloading_enabled?) ? config.reloading_enabled? : !config.cache_classes
|
|
19
|
+
next unless reloading
|
|
20
|
+
|
|
21
|
+
dir = File.dirname(Windsock.path.to_s)
|
|
22
|
+
next unless File.directory?(dir)
|
|
23
|
+
|
|
24
|
+
watcher = app.config.file_watcher.new([], { dir => ["css"] }) { Windsock.reset! }
|
|
25
|
+
app.reloaders << watcher
|
|
26
|
+
app.reloader.to_run { watcher.execute_if_updated }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pp"
|
|
4
|
+
|
|
5
|
+
module Windsock
|
|
6
|
+
# One color scale from the theme: scale(:brand) => 50..950 steps.
|
|
7
|
+
#
|
|
8
|
+
# Behaves like a Hash of step => Color (Enumerable over [step, color]
|
|
9
|
+
# pairs) and adds WCAG contrast lookups so you can pick a usable step
|
|
10
|
+
# without leaving the console.
|
|
11
|
+
#
|
|
12
|
+
# scale = Windsock.scale(:brand)
|
|
13
|
+
# scale[500].to_hex # "#2b7fff"
|
|
14
|
+
# scale.contrast(:white) # { 50 => 1.05, ..., 950 => 17.3 }
|
|
15
|
+
# scale.accessible(on: :white).steps # steps that pass AA for text
|
|
16
|
+
# scale.text_colors[500].to_hex # "#ffffff" — text to put on brand-500
|
|
17
|
+
#
|
|
18
|
+
# "white" and "black" mean the theme's --color-white / --color-black when
|
|
19
|
+
# the scale came from a theme (an app may soften white to #f5f5f5), and
|
|
20
|
+
# pure #fff / #000 otherwise. Any other name is looked up in the theme
|
|
21
|
+
# first (scale.contrast(:surface)), then parsed as CSS.
|
|
22
|
+
class Scale
|
|
23
|
+
include Enumerable
|
|
24
|
+
|
|
25
|
+
attr_reader :name
|
|
26
|
+
|
|
27
|
+
def initialize(name, steps, theme: nil)
|
|
28
|
+
@name = name.to_s
|
|
29
|
+
@theme = theme
|
|
30
|
+
@steps = steps.to_h { |step, color| [normalize(step), Color.parse!(color)] }.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def white
|
|
34
|
+
@theme&.white || Color::WHITE
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def black
|
|
38
|
+
@theme&.black || Color::BLACK
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# scale[500], scale["500"], scale[:muted] all work.
|
|
42
|
+
def [](step)
|
|
43
|
+
@steps[normalize(step)]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fetch(step)
|
|
47
|
+
self[step] || raise(NotFound, "scale #{name.inspect} has no step #{step.inspect}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def step?(step)
|
|
51
|
+
@steps.key?(normalize(step))
|
|
52
|
+
end
|
|
53
|
+
alias key? step?
|
|
54
|
+
|
|
55
|
+
def steps
|
|
56
|
+
@steps.keys
|
|
57
|
+
end
|
|
58
|
+
alias keys steps
|
|
59
|
+
|
|
60
|
+
def colors
|
|
61
|
+
@steps.values
|
|
62
|
+
end
|
|
63
|
+
alias values colors
|
|
64
|
+
|
|
65
|
+
def each(&)
|
|
66
|
+
return enum_for(:each) unless block_given?
|
|
67
|
+
|
|
68
|
+
@steps.each(&)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def size
|
|
72
|
+
@steps.size
|
|
73
|
+
end
|
|
74
|
+
alias length size
|
|
75
|
+
|
|
76
|
+
def empty?
|
|
77
|
+
@steps.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_h
|
|
81
|
+
@steps.dup
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# WCAG contrast ratio of every step against +on+ (a Color, a theme color
|
|
85
|
+
# name, or any CSS color): { 50 => 1.05, 100 => 1.12, ... }.
|
|
86
|
+
def contrast(on = :white)
|
|
87
|
+
other = resolve(on)
|
|
88
|
+
@steps.transform_values { |color| color.contrast(other).round(2) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The steps whose contrast against +on+ meets a WCAG level, as a new
|
|
92
|
+
# Scale. Levels: :aa (4.5), :aa_large (3), :aaa (7), :aaa_large (4.5).
|
|
93
|
+
#
|
|
94
|
+
# scale.accessible(on: :white) # text colors usable on white
|
|
95
|
+
# scale.accessible(on: :black, level: :aaa)
|
|
96
|
+
def accessible(on: :white, level: :aa)
|
|
97
|
+
other = resolve(on)
|
|
98
|
+
Scale.new(name, @steps.select { |_, color| color.accessible?(other, level: level) }, theme: @theme)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The theme's black or white, per step, for text placed on that step:
|
|
102
|
+
# { 50 => #000000, ..., 900 => #ffffff }.
|
|
103
|
+
def text_colors
|
|
104
|
+
@steps.transform_values { |color| color.text_color(white: white, black: black) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def ==(other)
|
|
108
|
+
other.is_a?(Scale) && name == other.name && to_h == other.to_h
|
|
109
|
+
end
|
|
110
|
+
alias eql? ==
|
|
111
|
+
|
|
112
|
+
def hash
|
|
113
|
+
[name, @steps].hash
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def to_s
|
|
117
|
+
"#{name}: " + @steps.map { |step, color| "#{step}=#{color.to_hex}" }.join(" ")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A table, one row per step: contrast against white and black, and the
|
|
121
|
+
# text color to use on the step. Ratios that meet +level+ (default AA,
|
|
122
|
+
# 4.5:1) print green, the rest red, when +color+ is on. +color+ defaults
|
|
123
|
+
# to whether stdout is a terminal, so logs and tests stay plain.
|
|
124
|
+
#
|
|
125
|
+
# scale.table # what the console shows
|
|
126
|
+
# scale.table(level: :aaa)
|
|
127
|
+
# puts scale.table(color: false)
|
|
128
|
+
def table(level: DEFAULT_LEVEL, color: $stdout.tty?)
|
|
129
|
+
return "#<Windsock::Scale #{name} (empty)>" if empty?
|
|
130
|
+
|
|
131
|
+
minimum = Color::WCAG.fetch(level) { raise ArgumentError, "level must be one of #{Color::WCAG.keys.join(", ")}" }
|
|
132
|
+
width = steps.map { |s| s.to_s.length }.max
|
|
133
|
+
rows = @steps.map do |step, c|
|
|
134
|
+
format(" %#{width}s %-9s %s %s %s", step, c.to_hex,
|
|
135
|
+
ratio_cell(c.contrast(white), minimum, color),
|
|
136
|
+
ratio_cell(c.contrast(black), minimum, color),
|
|
137
|
+
c.text_color(white: white, black: black) == white ? "white" : "black")
|
|
138
|
+
end
|
|
139
|
+
header = format(" %#{width}s %-9s %6s %6s %s", "step", "hex", "white", "black", "text")
|
|
140
|
+
title = "#<Windsock::Scale #{name} (#{size} steps; contrast vs white #{white.to_hex} / black #{black.to_hex}; " \
|
|
141
|
+
"#{level.to_s.upcase.tr("_", " ")} needs #{minimum}:1; text = what to write on it)"
|
|
142
|
+
[title, header, *rows, ">"].join("\n")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def inspect
|
|
146
|
+
table
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# IRB's pretty printer wraps anything that inspects as #<...> in green and
|
|
150
|
+
# re-lexes the rest as Ruby, so the first red cell ended its green and
|
|
151
|
+
# the rows after it came out half-coloured. Hand it the finished table
|
|
152
|
+
# through the base PrettyPrint#text so nothing is re-coloured.
|
|
153
|
+
def pretty_print(q)
|
|
154
|
+
PrettyPrint.instance_method(:text).bind_call(q, table)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
DEFAULT_LEVEL = :aa
|
|
158
|
+
GREEN = "\e[32m"
|
|
159
|
+
RED = "\e[31m"
|
|
160
|
+
RESET = "\e[0m"
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# :white / :black => the theme's; another theme color name => that;
|
|
165
|
+
# a Color => itself; anything else => parsed as CSS.
|
|
166
|
+
def resolve(on)
|
|
167
|
+
return on if on.is_a?(Color)
|
|
168
|
+
|
|
169
|
+
key = on.to_s
|
|
170
|
+
return white if key == "white"
|
|
171
|
+
return black if key == "black"
|
|
172
|
+
|
|
173
|
+
themed = @theme && @theme[:color, key]
|
|
174
|
+
Color.parse!(themed || key)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def ratio_cell(ratio, minimum, color)
|
|
178
|
+
cell = format("%6.2f", ratio)
|
|
179
|
+
return cell unless color
|
|
180
|
+
|
|
181
|
+
"#{ratio >= minimum ? GREEN : RED}#{cell}#{RESET}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def normalize(step)
|
|
185
|
+
text = step.to_s
|
|
186
|
+
text.match?(/\A\d+\z/) ? text.to_i : text
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Tailwind Labs, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|