klenod-build 0.0.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.
- checksums.yaml +7 -0
- data/README.md +29 -0
- data/lib/klenod/build/asset.rb +235 -0
- data/lib/klenod/build/asset_compression.rb +58 -0
- data/lib/klenod/build/asset_generation_queue.rb +50 -0
- data/lib/klenod/build/cli/application.rb +108 -0
- data/lib/klenod/build/cli.rb +3 -0
- data/lib/klenod/build/config.rb +142 -0
- data/lib/klenod/build/context.rb +370 -0
- data/lib/klenod/build/dependency.rb +21 -0
- data/lib/klenod/build/errors.rb +175 -0
- data/lib/klenod/build/filesystem_resolver.rb +147 -0
- data/lib/klenod/build/graph/invalidator.rb +246 -0
- data/lib/klenod/build/graph.rb +864 -0
- data/lib/klenod/build/graphviz.rb +282 -0
- data/lib/klenod/build/hashing.rb +21 -0
- data/lib/klenod/build/invalidation_result.rb +55 -0
- data/lib/klenod/build/load_result.rb +7 -0
- data/lib/klenod/build/loaded_module.rb +38 -0
- data/lib/klenod/build/module_id.rb +100 -0
- data/lib/klenod/build/module_record.rb +22 -0
- data/lib/klenod/build/plugin.rb +35 -0
- data/lib/klenod/build/plugins/class_names_runtime.rb +125 -0
- data/lib/klenod/build/plugins/component_defaults.rb +12 -0
- data/lib/klenod/build/plugins/data_plugin.rb +117 -0
- data/lib/klenod/build/plugins/gem_import_plugin.rb +125 -0
- data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.json +17687 -0
- data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.rb +105 -0
- data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.txt +31 -0
- data/lib/klenod/build/plugins/google_fonts_plugin.rb +387 -0
- data/lib/klenod/build/plugins/haml_plugin/companions.rb +40 -0
- data/lib/klenod/build/plugins/haml_plugin/errors.rb +114 -0
- data/lib/klenod/build/plugins/haml_plugin/helper_source.rb +123 -0
- data/lib/klenod/build/plugins/haml_plugin/parser.rb +85 -0
- data/lib/klenod/build/plugins/haml_plugin/transformer/ruby_builder.rb +1039 -0
- data/lib/klenod/build/plugins/haml_plugin/transformer.rb +766 -0
- data/lib/klenod/build/plugins/haml_plugin.rb +369 -0
- data/lib/klenod/build/plugins/image_plugin.rb +401 -0
- data/lib/klenod/build/plugins/intl_plugin.rb +34 -0
- data/lib/klenod/build/plugins/markdown_compiler.rb +180 -0
- data/lib/klenod/build/plugins/markdown_plugin.rb +161 -0
- data/lib/klenod/build/plugins/router_plugin.rb +942 -0
- data/lib/klenod/build/plugins/ruby_plugin.rb +34 -0
- data/lib/klenod/build/plugins/svg_plugin.rb +197 -0
- data/lib/klenod/build/plugins.rb +22 -0
- data/lib/klenod/build/profiler.rb +79 -0
- data/lib/klenod/build/resolution_error_formatter.rb +42 -0
- data/lib/klenod/build/resolver.rb +95 -0
- data/lib/klenod/build/ruby_import_rewriter.rb +436 -0
- data/lib/klenod/build/source_map/editor.rb +110 -0
- data/lib/klenod/build/source_map/map.rb +168 -0
- data/lib/klenod/build/source_map/vlq.rb +68 -0
- data/lib/klenod/build/source_map.rb +12 -0
- data/lib/klenod/build/transform_result.rb +12 -0
- data/lib/klenod/build/version.rb +7 -0
- data/lib/klenod/build/watched_pattern.rb +11 -0
- data/lib/klenod/build/watcher.rb +141 -0
- data/lib/klenod/build.rb +15 -0
- metadata +310 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Klenod
|
|
6
|
+
module Build
|
|
7
|
+
module Plugins
|
|
8
|
+
module GoogleFontsPlugin
|
|
9
|
+
FontMetric = Data.define(
|
|
10
|
+
:family_name,
|
|
11
|
+
:category,
|
|
12
|
+
:ascent,
|
|
13
|
+
:descent,
|
|
14
|
+
:line_gap,
|
|
15
|
+
:units_per_em,
|
|
16
|
+
:x_width_avg
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
FallbackFont = Data.define(
|
|
20
|
+
:family,
|
|
21
|
+
:local_family,
|
|
22
|
+
:size_adjust,
|
|
23
|
+
:ascent_override,
|
|
24
|
+
:descent_override,
|
|
25
|
+
:line_gap_override
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
class FontMetrics
|
|
29
|
+
DATA_PATH = File.expand_path("font_metrics.json", __dir__)
|
|
30
|
+
|
|
31
|
+
def initialize(path: DATA_PATH)
|
|
32
|
+
@path = path
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def [](family)
|
|
36
|
+
values = collection[family]
|
|
37
|
+
return unless values
|
|
38
|
+
|
|
39
|
+
FontMetric.new(
|
|
40
|
+
values.fetch("familyName"),
|
|
41
|
+
values.fetch("category"),
|
|
42
|
+
values.fetch("ascent"),
|
|
43
|
+
values.fetch("descent"),
|
|
44
|
+
values.fetch("lineGap"),
|
|
45
|
+
values.fetch("unitsPerEm"),
|
|
46
|
+
values.fetch("xWidthAvg")
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def collection
|
|
53
|
+
@collection ||= JSON.parse(File.binread(@path))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class FallbackCalculator
|
|
58
|
+
SERIF_FALLBACK = "Times New Roman"
|
|
59
|
+
MONO_FALLBACK = "Courier New"
|
|
60
|
+
SANS_SERIF_FALLBACK = "Arial"
|
|
61
|
+
|
|
62
|
+
def initialize(metrics)
|
|
63
|
+
@metrics = metrics
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def call(family)
|
|
67
|
+
font = metrics[family]
|
|
68
|
+
return unless font
|
|
69
|
+
|
|
70
|
+
local_family =
|
|
71
|
+
case font.category
|
|
72
|
+
when "serif" then SERIF_FALLBACK
|
|
73
|
+
when "monospace" then MONO_FALLBACK
|
|
74
|
+
else SANS_SERIF_FALLBACK
|
|
75
|
+
end
|
|
76
|
+
fallback = metrics[local_family]
|
|
77
|
+
return unless fallback
|
|
78
|
+
|
|
79
|
+
font_average_width = font.x_width_avg.fdiv(font.units_per_em)
|
|
80
|
+
fallback_average_width = fallback.x_width_avg.fdiv(fallback.units_per_em)
|
|
81
|
+
size_adjust = (font_average_width.zero? || fallback_average_width.zero?) ? 1 : font_average_width / fallback_average_width
|
|
82
|
+
adjusted_em_square = font.units_per_em * size_adjust
|
|
83
|
+
|
|
84
|
+
FallbackFont.new(
|
|
85
|
+
"#{family} Fallback",
|
|
86
|
+
local_family,
|
|
87
|
+
percentage(size_adjust),
|
|
88
|
+
percentage(font.ascent.fdiv(adjusted_em_square)),
|
|
89
|
+
percentage(font.descent.fdiv(adjusted_em_square)),
|
|
90
|
+
percentage(font.line_gap.fdiv(adjusted_em_square))
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
attr_reader :metrics
|
|
97
|
+
|
|
98
|
+
def percentage(value)
|
|
99
|
+
format("%.2f%%", value.abs * 100)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Font metrics in font_metrics.json are taken from the Capsize metrics collection.
|
|
2
|
+
|
|
3
|
+
Update them to the latest Capsize revision from the Klenod repository root:
|
|
4
|
+
|
|
5
|
+
bundle exec rake google_fonts:metrics:update
|
|
6
|
+
|
|
7
|
+
Project: https://github.com/seek-oss/capsize
|
|
8
|
+
Commit: b0eb891c39e2ecd8b680f550bcda6f5fe9e38326
|
|
9
|
+
Source: packages/metrics/src/entireMetricsCollection.json
|
|
10
|
+
|
|
11
|
+
MIT License
|
|
12
|
+
|
|
13
|
+
Copyright (c) 2021 SEEK
|
|
14
|
+
|
|
15
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
16
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
17
|
+
in the Software without restriction, including without limitation the rights
|
|
18
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
19
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
20
|
+
furnished to do so, subject to the following conditions:
|
|
21
|
+
|
|
22
|
+
The above copyright notice and this permission notice shall be included in all
|
|
23
|
+
copies or substantial portions of the Software.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
27
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
28
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
29
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
30
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
31
|
+
SOFTWARE.
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/http/internet"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require_relative "../asset"
|
|
9
|
+
require_relative "../dependency"
|
|
10
|
+
require_relative "../errors"
|
|
11
|
+
require_relative "../hashing"
|
|
12
|
+
require_relative "../module_id"
|
|
13
|
+
require_relative "../plugin"
|
|
14
|
+
require_relative "../transform_result"
|
|
15
|
+
require_relative "google_fonts_plugin/font_metrics"
|
|
16
|
+
|
|
17
|
+
module Klenod
|
|
18
|
+
module Build
|
|
19
|
+
module Plugins
|
|
20
|
+
module GoogleFontsPlugin
|
|
21
|
+
def self.new(...)
|
|
22
|
+
Plugin.new(...)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Plugin < Klenod::Build::Plugin
|
|
26
|
+
GOOGLE_FONTS_HOST = "fonts.googleapis.com"
|
|
27
|
+
GOOGLE_FONTS_PATH = "/css2"
|
|
28
|
+
GOOGLE_FONTS_MODULE_PREFIX = "virtual:klenod/google_fonts"
|
|
29
|
+
FONT_URL_PATTERN = /url\((?<quote>["']?)(?<url>https:\/\/fonts\.gstatic\.com\/[^"')]+)\k<quote>\)/
|
|
30
|
+
|
|
31
|
+
Error = Class.new(StandardError)
|
|
32
|
+
FontFace = Data.define(:family, :style, :weight)
|
|
33
|
+
|
|
34
|
+
class CssCache
|
|
35
|
+
def initialize(path)
|
|
36
|
+
@path = path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def read(url)
|
|
40
|
+
path = entry_path(url)
|
|
41
|
+
return nil unless File.file?(path)
|
|
42
|
+
|
|
43
|
+
css = File.binread(path)
|
|
44
|
+
css.empty? ? nil : css
|
|
45
|
+
rescue SystemCallError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def write(url, css)
|
|
50
|
+
path = entry_path(url)
|
|
51
|
+
temp_path = "#{path}.tmp.#{$$}.#{object_id}"
|
|
52
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
53
|
+
File.binwrite(temp_path, css)
|
|
54
|
+
File.rename(temp_path, path)
|
|
55
|
+
css
|
|
56
|
+
rescue
|
|
57
|
+
FileUtils.rm_f(temp_path) if temp_path
|
|
58
|
+
raise
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def entry_path(url)
|
|
64
|
+
hash = Hashing.hexdigest(url)
|
|
65
|
+
File.join(@path, "#{hash}.css")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class DefaultFetcher
|
|
70
|
+
def initialize(internet: Async::HTTP::Internet.new)
|
|
71
|
+
@internet = internet
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def call(url)
|
|
75
|
+
Sync do
|
|
76
|
+
@internet.get(url) do |response|
|
|
77
|
+
raise Error, "HTTP #{response.status}" unless response.success?
|
|
78
|
+
|
|
79
|
+
response.read
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def write(url, io)
|
|
85
|
+
Sync do
|
|
86
|
+
@internet.get(url) do |response|
|
|
87
|
+
raise Error, "HTTP #{response.status}" unless response.success?
|
|
88
|
+
|
|
89
|
+
if response.body
|
|
90
|
+
response.body.each { |chunk| io.write(chunk) }
|
|
91
|
+
else
|
|
92
|
+
io.write(response.read)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class FontFaceParser
|
|
100
|
+
FONT_FACE_PATTERN = /@font-face\s*\{(?<body>.*?)\}/m
|
|
101
|
+
FONT_URL_PATTERN = Plugin::FONT_URL_PATTERN
|
|
102
|
+
DECLARATION_PATTERN = /(?<name>font-family|font-style|font-weight)\s*:\s*(?<value>[^;]+);/i
|
|
103
|
+
|
|
104
|
+
def initialize(css)
|
|
105
|
+
@css = css
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def font_faces_by_url
|
|
109
|
+
css.scan(FONT_FACE_PATTERN).each_with_object({}) do |(body), index|
|
|
110
|
+
font_url = body.match(FONT_URL_PATTERN) { it[:url] }
|
|
111
|
+
next unless font_url
|
|
112
|
+
|
|
113
|
+
declarations = font_face_declarations(body)
|
|
114
|
+
index[font_url] =
|
|
115
|
+
FontFace.new(
|
|
116
|
+
unquote(declarations["font-family"]),
|
|
117
|
+
declarations["font-style"],
|
|
118
|
+
declarations["font-weight"]
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
attr_reader :css
|
|
126
|
+
|
|
127
|
+
def font_face_declarations(body)
|
|
128
|
+
body.scan(DECLARATION_PATTERN).to_h do |name, value|
|
|
129
|
+
[name.downcase, value.strip]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def unquote(value)
|
|
134
|
+
value&.delete_prefix("\"")&.delete_suffix("\"")&.delete_prefix("'")&.delete_suffix("'")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def initialize(fetcher: nil, cache_path: nil, refresh_cache: false, adjust_font_fallback: true)
|
|
139
|
+
@fetcher = fetcher || DefaultFetcher.new
|
|
140
|
+
@css_cache = cache_path && CssCache.new(cache_path)
|
|
141
|
+
@refresh_cache = refresh_cache
|
|
142
|
+
@adjust_font_fallback = adjust_font_fallback
|
|
143
|
+
@fallback_calculator = FallbackCalculator.new(FontMetrics.new)
|
|
144
|
+
@missing_fallback_metrics = {}
|
|
145
|
+
@assets_by_module_id = {}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def resolve(dependency, _context)
|
|
149
|
+
return nil unless dependency.kind == :css_import
|
|
150
|
+
return nil unless google_fonts_url?(dependency.specifier)
|
|
151
|
+
|
|
152
|
+
hash = Hashing.short(dependency.specifier)
|
|
153
|
+
module_id =
|
|
154
|
+
ModuleId.new(
|
|
155
|
+
"#{GOOGLE_FONTS_MODULE_PREFIX}/#{hash}.rb",
|
|
156
|
+
URI.encode_www_form("url" => dependency.specifier)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
ResolvedDependency.new(dependency, module_id, {virtual: true})
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def load(module_id, context)
|
|
163
|
+
return nil unless google_fonts_module?(module_id)
|
|
164
|
+
|
|
165
|
+
url = google_fonts_url_for(module_id)
|
|
166
|
+
css = fetch_css(url)
|
|
167
|
+
font_faces = FontFaceParser.new(css).font_faces_by_url
|
|
168
|
+
font_assets = {}
|
|
169
|
+
rewritten_css =
|
|
170
|
+
css.gsub(FONT_URL_PATTERN) do
|
|
171
|
+
quote = Regexp.last_match[:quote]
|
|
172
|
+
font_url = Regexp.last_match[:url]
|
|
173
|
+
asset = font_assets[font_url] ||= font_asset(font_url, font_faces[font_url], context)
|
|
174
|
+
|
|
175
|
+
%(url(#{quote}#{asset.output_path}#{quote}))
|
|
176
|
+
end
|
|
177
|
+
rewritten_css = append_fallback_font_faces(rewritten_css, font_faces.values) if @adjust_font_fallback
|
|
178
|
+
|
|
179
|
+
css_asset = css_asset(module_id, url, rewritten_css, font_assets.keys)
|
|
180
|
+
@assets_by_module_id[module_id] = [css_asset, *font_assets.values]
|
|
181
|
+
ruby_module_source(css_asset.output_path)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def transform(module_id, code, _context)
|
|
185
|
+
return super unless google_fonts_module?(module_id)
|
|
186
|
+
|
|
187
|
+
TransformResult.new(
|
|
188
|
+
code,
|
|
189
|
+
[],
|
|
190
|
+
nil,
|
|
191
|
+
@assets_by_module_id.fetch(module_id),
|
|
192
|
+
[],
|
|
193
|
+
{google_fonts_url: google_fonts_url_for(module_id)}
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
def google_fonts_url?(value)
|
|
200
|
+
uri = URI.parse(value)
|
|
201
|
+
uri.is_a?(URI::HTTPS) &&
|
|
202
|
+
uri.host == GOOGLE_FONTS_HOST &&
|
|
203
|
+
uri.path == GOOGLE_FONTS_PATH
|
|
204
|
+
rescue URI::InvalidURIError
|
|
205
|
+
false
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def google_fonts_module?(module_id)
|
|
209
|
+
module_id.scheme == :virtual && module_id.path.start_with?("#{GOOGLE_FONTS_MODULE_PREFIX}/")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def google_fonts_url_for(module_id)
|
|
213
|
+
URI.decode_www_form(module_id.query || "").to_h.fetch("url")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def fetch(url)
|
|
217
|
+
@fetcher.call(url).b
|
|
218
|
+
rescue => error
|
|
219
|
+
raise Error, "Could not download Google Fonts asset #{url.inspect}: #{error.message}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def fetch_css(url)
|
|
223
|
+
if @css_cache && !@refresh_cache
|
|
224
|
+
css = @css_cache.read(url)
|
|
225
|
+
return css if css
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
css = fetch(url)
|
|
229
|
+
@css_cache&.write(url, css)
|
|
230
|
+
css
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def write_fetch(url, io)
|
|
234
|
+
if @fetcher.respond_to?(:write)
|
|
235
|
+
@fetcher.write(url, io)
|
|
236
|
+
else
|
|
237
|
+
io.write(fetch(url))
|
|
238
|
+
end
|
|
239
|
+
rescue => error
|
|
240
|
+
raise Error, "Could not download Google Fonts asset #{url.inspect}: #{error.message}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def css_asset(module_id, url, css, font_source_urls)
|
|
244
|
+
hash = Hashing.short(css)
|
|
245
|
+
output_path = "/assets/#{google_fonts_asset_name(url)}.#{hash}.css"
|
|
246
|
+
|
|
247
|
+
Asset.new(
|
|
248
|
+
module_id.to_s,
|
|
249
|
+
hash,
|
|
250
|
+
output_path,
|
|
251
|
+
nil,
|
|
252
|
+
css,
|
|
253
|
+
"text/css",
|
|
254
|
+
{type: :css, google_fonts: true, font_source_urls: font_source_urls}
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def append_fallback_font_faces(css, font_faces)
|
|
259
|
+
fallback_css =
|
|
260
|
+
font_faces
|
|
261
|
+
.filter_map(&:family)
|
|
262
|
+
.uniq
|
|
263
|
+
.filter_map { fallback_font_face_css(it) }
|
|
264
|
+
return css if fallback_css.empty?
|
|
265
|
+
|
|
266
|
+
"#{css.rstrip}\n\n#{fallback_css.join("\n\n")}\n"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def fallback_font_face_css(family)
|
|
270
|
+
fallback = @fallback_calculator.call(family)
|
|
271
|
+
unless fallback
|
|
272
|
+
unless @missing_fallback_metrics[family]
|
|
273
|
+
warn "Could not adjust fallback for Google Font #{family.inspect}; run `bundle exec rake google_fonts:metrics:update` to refresh the vendored metrics."
|
|
274
|
+
@missing_fallback_metrics[family] = true
|
|
275
|
+
end
|
|
276
|
+
return
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
<<~CSS.rstrip
|
|
280
|
+
@font-face {
|
|
281
|
+
font-family: #{css_string(fallback.family)};
|
|
282
|
+
src: local(#{css_string(fallback.local_family)});
|
|
283
|
+
size-adjust: #{fallback.size_adjust};
|
|
284
|
+
ascent-override: #{fallback.ascent_override};
|
|
285
|
+
descent-override: #{fallback.descent_override};
|
|
286
|
+
line-gap-override: #{fallback.line_gap_override};
|
|
287
|
+
}
|
|
288
|
+
CSS
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def css_string(value)
|
|
292
|
+
%("#{value.gsub("\\", "\\\\").gsub("\"", "\\\"")}")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def font_asset(url, font_face, context)
|
|
296
|
+
hash = Hashing.short(url)
|
|
297
|
+
uri = URI.parse(url)
|
|
298
|
+
extname = File.extname(uri.path)
|
|
299
|
+
output_path = "/assets/#{font_asset_name(uri, font_face)}.#{hash}#{extname}"
|
|
300
|
+
|
|
301
|
+
Asset.generated(
|
|
302
|
+
url,
|
|
303
|
+
hash,
|
|
304
|
+
output_path,
|
|
305
|
+
nil,
|
|
306
|
+
content_type(extname),
|
|
307
|
+
{
|
|
308
|
+
type: :font,
|
|
309
|
+
google_fonts: true,
|
|
310
|
+
source_url: url,
|
|
311
|
+
family: font_face&.family,
|
|
312
|
+
style: font_face&.style,
|
|
313
|
+
weight: font_face&.weight
|
|
314
|
+
},
|
|
315
|
+
writer: ->(io) { write_fetch(url, io) },
|
|
316
|
+
queue: context.asset_generation_queue,
|
|
317
|
+
queue_kind: :io
|
|
318
|
+
) do
|
|
319
|
+
fetch(url)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def ruby_module_source(css_asset_path)
|
|
324
|
+
<<~RUBY
|
|
325
|
+
CSS_CLASSES = {}.freeze
|
|
326
|
+
CSS_ASSET_PATH = #{css_asset_path.inspect}
|
|
327
|
+
RUBY
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def google_fonts_asset_name(url)
|
|
331
|
+
uri = URI.parse(url)
|
|
332
|
+
families =
|
|
333
|
+
URI
|
|
334
|
+
.decode_www_form(uri.query || "")
|
|
335
|
+
.filter_map do |key, value|
|
|
336
|
+
next unless key == "family"
|
|
337
|
+
|
|
338
|
+
value.split(":", 2).fetch(0)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
slug = families.empty? ? "fonts" : families.join("_")
|
|
342
|
+
"google_fonts_#{slugify(slug)}"
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def font_asset_name(uri, font_face)
|
|
346
|
+
return font_face_asset_name(font_face) if font_face&.family
|
|
347
|
+
|
|
348
|
+
parts = uri.path.split("/").reject(&:empty?)
|
|
349
|
+
useful_parts = parts.last(3)
|
|
350
|
+
useful_parts[-1] = File.basename(useful_parts.fetch(-1), File.extname(useful_parts.fetch(-1)))
|
|
351
|
+
|
|
352
|
+
"google_font_#{slugify(useful_parts.join("_"))}"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def font_face_asset_name(font_face)
|
|
356
|
+
parts = [font_face.family, font_face.style, font_face.weight].compact
|
|
357
|
+
|
|
358
|
+
"google_font_#{slugify(parts.join("_"))}"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def slugify(value)
|
|
362
|
+
value
|
|
363
|
+
.downcase
|
|
364
|
+
.gsub(/[^a-z0-9]+/, "_")
|
|
365
|
+
.sub(/\A_+/, "")
|
|
366
|
+
.sub(/_+\z/, "")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def content_type(extname)
|
|
370
|
+
case extname
|
|
371
|
+
when ".woff2" then "font/woff2"
|
|
372
|
+
when ".woff" then "font/woff"
|
|
373
|
+
when ".ttf" then "font/ttf"
|
|
374
|
+
when ".otf" then "font/otf"
|
|
375
|
+
else "application/octet-stream"
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
Error = Plugin::Error
|
|
381
|
+
FontFace = Plugin::FontFace
|
|
382
|
+
CssCache = Plugin::CssCache
|
|
383
|
+
DefaultFetcher = Plugin::DefaultFetcher
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klenod
|
|
4
|
+
module Build
|
|
5
|
+
module Plugins
|
|
6
|
+
module HamlPlugin
|
|
7
|
+
module Companions
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def companion_patterns(module_id)
|
|
11
|
+
base = module_id.path.delete_suffix(".haml")
|
|
12
|
+
|
|
13
|
+
[
|
|
14
|
+
WatchedPattern.new(module_id, "#{base}.css", :companion_style, {}),
|
|
15
|
+
WatchedPattern.new(module_id, "#{base}.intl.*.toml", :companion_intl, {})
|
|
16
|
+
]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def companion_path(module_id, extname)
|
|
20
|
+
ModuleId.new(module_id.path.delete_suffix(".haml") + extname, nil)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def companion_owner_module_id(path, context)
|
|
24
|
+
relative_path = Pathname.new(path).expand_path.relative_path_from(context.source_dir).to_s
|
|
25
|
+
owner_path =
|
|
26
|
+
if relative_path.end_with?(".css")
|
|
27
|
+
relative_path.delete_suffix(".css") + ".haml"
|
|
28
|
+
elsif relative_path.match?(/\.intl\.[^\/]+\.toml\z/)
|
|
29
|
+
relative_path.sub(/\.intl\.[^\/]+\.toml\z/, ".haml")
|
|
30
|
+
end
|
|
31
|
+
return nil unless owner_path
|
|
32
|
+
|
|
33
|
+
owner_id = ModuleId.new(owner_path, nil)
|
|
34
|
+
owner_id if context.absolute_path(owner_id).file?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "klenod/runtime/source_map"
|
|
4
|
+
|
|
5
|
+
module Klenod
|
|
6
|
+
module Build
|
|
7
|
+
module Plugins
|
|
8
|
+
module HamlPlugin
|
|
9
|
+
class ParseError < StandardError
|
|
10
|
+
attr_reader :module_id, :source, :line, :column, :cause
|
|
11
|
+
|
|
12
|
+
def initialize(error, source:, module_id:)
|
|
13
|
+
@cause = error
|
|
14
|
+
@module_id = module_id
|
|
15
|
+
@source = source
|
|
16
|
+
@line = source_line_for(error)
|
|
17
|
+
@column = nil
|
|
18
|
+
|
|
19
|
+
super(message_for(error))
|
|
20
|
+
set_backtrace(error.backtrace)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def source_line_for(error)
|
|
26
|
+
line = error.line if error.respond_to?(:line)
|
|
27
|
+
line ||= full_message_line_for(error)
|
|
28
|
+
return nil unless line.is_a?(Integer)
|
|
29
|
+
|
|
30
|
+
# Haml reports zero-based line indexes.
|
|
31
|
+
error_line_zero_based?(error) ? line + 1 : line
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def full_message_line_for(error)
|
|
35
|
+
return nil unless error.respond_to?(:full_message)
|
|
36
|
+
|
|
37
|
+
error.full_message(highlight: false, order: :top).match(/\A\(haml\):(?<line>\d+):/) { it[:line].to_i }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def error_line_zero_based?(error)
|
|
41
|
+
error.respond_to?(:line) && error.line.is_a?(Integer) && !error.is_a?(RubyParseError)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def message_for(error)
|
|
45
|
+
location =
|
|
46
|
+
if module_id && line
|
|
47
|
+
"#{module_id}:#{line}"
|
|
48
|
+
elsif module_id
|
|
49
|
+
module_id.to_s
|
|
50
|
+
elsif line
|
|
51
|
+
"line #{line}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
title = location ? "#{location}: Haml parse error" : "Haml parse error"
|
|
55
|
+
|
|
56
|
+
[
|
|
57
|
+
title,
|
|
58
|
+
error.message,
|
|
59
|
+
source_excerpt
|
|
60
|
+
].compact.join("\n\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def source_excerpt
|
|
64
|
+
return nil unless line
|
|
65
|
+
|
|
66
|
+
lines = source.lines
|
|
67
|
+
return nil if lines.empty?
|
|
68
|
+
|
|
69
|
+
index = line - 1
|
|
70
|
+
first = [index - 2, 0].max
|
|
71
|
+
last = [index + 2, lines.length - 1].min
|
|
72
|
+
width = (last + 1).to_s.length
|
|
73
|
+
excerpt =
|
|
74
|
+
(first..last).map do |line_index|
|
|
75
|
+
marker = (line_index == index) ? ">" : " "
|
|
76
|
+
number = (line_index + 1).to_s.rjust(width)
|
|
77
|
+
formatted = "#{marker} #{number} | #{lines.fetch(line_index).chomp}"
|
|
78
|
+
if marker == ">"
|
|
79
|
+
"\e[1;31m#{formatted}\e[0m"
|
|
80
|
+
else
|
|
81
|
+
formatted
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
"Source:\n#{excerpt.join("\n")}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class RubyParseError < StandardError
|
|
90
|
+
attr_reader :line
|
|
91
|
+
|
|
92
|
+
def initialize(message, line: nil)
|
|
93
|
+
@line = line
|
|
94
|
+
|
|
95
|
+
super(message)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
HamlTransformResult = Data.define(:code, :source_map, :metadata, :ast) do
|
|
100
|
+
def self.from_ast(ast, source:, metadata:)
|
|
101
|
+
code = ast.source
|
|
102
|
+
|
|
103
|
+
new(
|
|
104
|
+
code,
|
|
105
|
+
Runtime::SourceMap::SourceMap.parse(source, code),
|
|
106
|
+
metadata,
|
|
107
|
+
ast
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|