fontist 2.1.1 → 2.1.3
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 +4 -4
- data/.github/workflows/deploy-pages.yml +6 -0
- data/.github/workflows/formulas-auto-update-test.yml +287 -0
- data/.github/workflows/post-rake.yml +5 -1
- data/.github/workflows/rake-metanorma.yaml +24 -3
- data/.github/workflows/rake.yml +4 -1
- data/.github/workflows/release.yml +6 -0
- data/.github/workflows/release.yml.orig +36 -0
- data/.github/workflows/tebako-pack.yml +4 -0
- data/.gitignore +7 -6
- data/README.adoc +100 -0
- data/docs/package-lock.json +1610 -736
- data/docs/package.json +6 -6
- data/fontist.gemspec +2 -0
- data/lib/fontist/cache/store.rb +31 -2
- data/lib/fontist/font_file.rb +65 -14
- data/lib/fontist/indexes/index_mixin.rb +109 -20
- data/lib/fontist/manifest.rb +4 -4
- data/lib/fontist/system_index.rb +30 -1
- data/lib/fontist/utils/cache.rb +1 -0
- data/lib/fontist/utils/downloader.rb +38 -4
- data/lib/fontist/utils/github_client.rb +43 -0
- data/lib/fontist/utils/github_url.rb +51 -0
- data/lib/fontist/version.rb +1 -1
- data/lib/fontist.rb +6 -3
- metadata +35 -3
- /data/{docs/google-fonts-multi-format-usage.md → google-fonts-multi-format-usage.md} +0 -0
data/docs/package.json
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
9
|
"devDependencies": {
|
|
10
|
-
"@types/node": "
|
|
11
|
-
"prettier": "^3.
|
|
12
|
-
"typescript": "
|
|
13
|
-
"vitepress": "^1.
|
|
14
|
-
"vue": "^3.
|
|
15
|
-
"vue-tsc": "^
|
|
10
|
+
"@types/node": "^25.0.0",
|
|
11
|
+
"prettier": "^3.8.1",
|
|
12
|
+
"typescript": "^5.9.3",
|
|
13
|
+
"vitepress": "^1.6.4",
|
|
14
|
+
"vue": "^3.5.29",
|
|
15
|
+
"vue-tsc": "^3.2.5"
|
|
16
16
|
}
|
|
17
17
|
}
|
data/fontist.gemspec
CHANGED
|
@@ -36,8 +36,10 @@ Gem::Specification.new do |spec|
|
|
|
36
36
|
spec.add_dependency "git", "> 1.0"
|
|
37
37
|
spec.add_dependency "json", "~> 2.0"
|
|
38
38
|
spec.add_dependency "lutaml-model", "~> 0.7"
|
|
39
|
+
spec.add_dependency "lutaml-xsd", "~> 1.0"
|
|
39
40
|
spec.add_dependency "marcel", "~> 1.0"
|
|
40
41
|
spec.add_dependency "nokogiri", "~> 1.0"
|
|
42
|
+
spec.add_dependency "octokit", "~> 4.0"
|
|
41
43
|
spec.add_dependency "paint", "~> 2.3"
|
|
42
44
|
spec.add_dependency "parallel", "~> 1.24"
|
|
43
45
|
spec.add_dependency "plist", "~> 3.0"
|
data/lib/fontist/cache/store.rb
CHANGED
|
@@ -42,6 +42,16 @@ module Fontist
|
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
def cleanup_temp_files
|
|
46
|
+
# Clean up orphaned .tmp files from interrupted writes
|
|
47
|
+
# This can happen if the process crashes between File.write and File.rename
|
|
48
|
+
Dir.glob(File.join(@cache_dir, "*.tmp")).each do |tmp|
|
|
49
|
+
File.delete(tmp)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
45
55
|
private
|
|
46
56
|
|
|
47
57
|
attr_reader :cache_dir
|
|
@@ -68,11 +78,30 @@ module Fontist
|
|
|
68
78
|
def read_entry(key)
|
|
69
79
|
return nil unless File.exist?(cache_path(key))
|
|
70
80
|
|
|
71
|
-
|
|
81
|
+
begin
|
|
82
|
+
Marshal.load(File.read(cache_path(key)))
|
|
83
|
+
rescue ArgumentError, TypeError => e
|
|
84
|
+
# Cache file is corrupted - delete it and return nil
|
|
85
|
+
# This can happen on Windows when file is read while being written,
|
|
86
|
+
# or when cache files from previous runs are corrupted
|
|
87
|
+
File.delete(cache_path(key)) rescue nil
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
72
90
|
end
|
|
73
91
|
|
|
74
92
|
def write_entry(key, entry)
|
|
75
|
-
|
|
93
|
+
# Use temp file + atomic rename to prevent race conditions
|
|
94
|
+
# This ensures readers never see partial writes, even on Windows
|
|
95
|
+
temp_path = cache_path(key) + ".tmp"
|
|
96
|
+
|
|
97
|
+
File.write(temp_path, Marshal.dump(entry))
|
|
98
|
+
# Atomic rename (overwrites target atomically)
|
|
99
|
+
# File.rename is atomic on all platforms for same filesystem
|
|
100
|
+
File.rename(temp_path, cache_path(key))
|
|
101
|
+
rescue => e
|
|
102
|
+
# Clean up temp file if rename fails
|
|
103
|
+
File.delete(temp_path) rescue nil
|
|
104
|
+
raise e
|
|
76
105
|
end
|
|
77
106
|
|
|
78
107
|
# Cache entry with TTL support
|
data/lib/fontist/font_file.rb
CHANGED
|
@@ -42,11 +42,11 @@ module Fontist
|
|
|
42
42
|
|
|
43
43
|
# For collections, we need different handling
|
|
44
44
|
if is_collection
|
|
45
|
-
# Load
|
|
45
|
+
# Load the first font in the collection
|
|
46
46
|
font = Fontisan::FontLoader.load(path, font_index: 0,
|
|
47
47
|
mode: :metadata, lazy: true)
|
|
48
48
|
|
|
49
|
-
# Validate the font using indexability profile
|
|
49
|
+
# Validate the font using indexability profile (log issues but don't reject)
|
|
50
50
|
validator = load_indexability_validator
|
|
51
51
|
validation_report = validator.validate(font)
|
|
52
52
|
|
|
@@ -54,10 +54,9 @@ module Fontist
|
|
|
54
54
|
error_messages = validation_report.errors.map do |e|
|
|
55
55
|
"#{e.category}: #{e.message}"
|
|
56
56
|
end.join("; ")
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
# rubocop:enable Layout/LineLength
|
|
57
|
+
Fontist.ui.debug(
|
|
58
|
+
"Font validation issues for #{File.basename(path)}: #{error_messages}",
|
|
59
|
+
)
|
|
61
60
|
end
|
|
62
61
|
|
|
63
62
|
else
|
|
@@ -82,11 +81,14 @@ module Fontist
|
|
|
82
81
|
validation_report = validator.validate(font)
|
|
83
82
|
|
|
84
83
|
unless validation_report.valid?
|
|
84
|
+
# Log validation issues but don't reject outright
|
|
85
|
+
# We'll check if we can still extract metadata
|
|
85
86
|
error_messages = validation_report.errors.map do |e|
|
|
86
87
|
"#{e.category}: #{e.message}"
|
|
87
88
|
end.join("; ")
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
Fontist.ui.debug(
|
|
90
|
+
"Font validation issues for #{File.basename(path)}: #{error_messages}",
|
|
91
|
+
)
|
|
90
92
|
end
|
|
91
93
|
|
|
92
94
|
font
|
|
@@ -180,17 +182,66 @@ module Fontist
|
|
|
180
182
|
return {} unless name_table
|
|
181
183
|
|
|
182
184
|
# Extract all needed name strings using Fontisan's API
|
|
185
|
+
# Fall back to non-English names if English names are not available
|
|
183
186
|
{
|
|
184
|
-
full_name: name_table.english_name(Fontisan::Tables::Name::FULL_NAME)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
full_name: name_table.english_name(Fontisan::Tables::Name::FULL_NAME) ||
|
|
188
|
+
extract_any_name(name_table, Fontisan::Tables::Name::FULL_NAME),
|
|
189
|
+
family_name: name_table.english_name(Fontisan::Tables::Name::FAMILY) ||
|
|
190
|
+
extract_any_name(name_table, Fontisan::Tables::Name::FAMILY),
|
|
191
|
+
subfamily_name: name_table.english_name(Fontisan::Tables::Name::SUBFAMILY) ||
|
|
192
|
+
extract_any_name(name_table, Fontisan::Tables::Name::SUBFAMILY),
|
|
193
|
+
preferred_family: name_table.english_name(Fontisan::Tables::Name::PREFERRED_FAMILY) ||
|
|
194
|
+
extract_any_name(name_table, Fontisan::Tables::Name::PREFERRED_FAMILY),
|
|
195
|
+
preferred_subfamily: name_table.english_name(Fontisan::Tables::Name::PREFERRED_SUBFAMILY) ||
|
|
196
|
+
extract_any_name(name_table, Fontisan::Tables::Name::PREFERRED_SUBFAMILY),
|
|
197
|
+
postscript_name: name_table.english_name(Fontisan::Tables::Name::POSTSCRIPT_NAME) ||
|
|
198
|
+
extract_any_name(name_table, Fontisan::Tables::Name::POSTSCRIPT_NAME),
|
|
190
199
|
}
|
|
191
200
|
end
|
|
192
201
|
# rubocop:enable Metrics/MethodLength
|
|
193
202
|
|
|
203
|
+
# Extract any available name for a given name ID by manually decoding the name record
|
|
204
|
+
def extract_any_name(name_table, name_id)
|
|
205
|
+
# Find any name record with the matching name_id
|
|
206
|
+
records = name_table.name_records.select { |r| r.name_id == name_id }
|
|
207
|
+
return nil if records.empty?
|
|
208
|
+
|
|
209
|
+
# Manually decode each record
|
|
210
|
+
records.each do |record|
|
|
211
|
+
decoded = decode_name_record_manually(name_table, record)
|
|
212
|
+
return decoded unless decoded.nil? || decoded.empty?
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Manually decode a name record since the fontisan method is private
|
|
219
|
+
def decode_name_record_manually(name_table, record)
|
|
220
|
+
# Get raw string storage
|
|
221
|
+
storage_bytes = name_table.string_storage.to_s.b
|
|
222
|
+
|
|
223
|
+
# Extract this record's string
|
|
224
|
+
offset = record.string_offset
|
|
225
|
+
length = record.string_length
|
|
226
|
+
|
|
227
|
+
return nil if offset + length > storage_bytes.bytesize
|
|
228
|
+
return nil if length.zero?
|
|
229
|
+
|
|
230
|
+
string_data = storage_bytes.byteslice(offset, length)
|
|
231
|
+
|
|
232
|
+
# Decode based on platform (same logic as fontisan)
|
|
233
|
+
case record.platform_id
|
|
234
|
+
when 0, 3 # PLATFORM_UNICODE, PLATFORM_WINDOWS
|
|
235
|
+
string_data.dup.force_encoding("UTF-16BE")
|
|
236
|
+
.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
237
|
+
when 1 # PLATFORM_MACINTOSH
|
|
238
|
+
string_data.dup.force_encoding("ASCII-8BIT")
|
|
239
|
+
.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
240
|
+
else
|
|
241
|
+
string_data.dup.force_encoding("UTF-8")
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
194
245
|
def raise_font_file_error(exception)
|
|
195
246
|
raise Errors::FontFileError,
|
|
196
247
|
"Font file could not be parsed: #{exception.inspect}."
|
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
module Fontist
|
|
2
2
|
module Indexes
|
|
3
|
+
# IndexMixin provides common functionality for font index classes.
|
|
4
|
+
#
|
|
5
|
+
# == Performance Optimization (Tech Debt)
|
|
6
|
+
#
|
|
7
|
+
# This module uses a temporary Hash-based lookup cache during index building
|
|
8
|
+
# to avoid O(n²) performance when adding many entries. This is a workaround
|
|
9
|
+
# for Lutaml::Model::Collection's Array-based storage.
|
|
10
|
+
#
|
|
11
|
+
# === The Problem
|
|
12
|
+
#
|
|
13
|
+
# Lutaml::Model::Collection stores entries as an Array, which provides O(n)
|
|
14
|
+
# lookup when searching for existing keys. When building an index with
|
|
15
|
+
# thousands of entries, this creates O(n²) behavior:
|
|
16
|
+
#
|
|
17
|
+
# - 8867 font styles × average 2670 comparisons = ~23.6 million comparisons
|
|
18
|
+
# - Index building: ~26 seconds with Array lookup
|
|
19
|
+
#
|
|
20
|
+
# === The Workaround
|
|
21
|
+
#
|
|
22
|
+
# During `build` and `build_with_formulas`, we maintain a temporary
|
|
23
|
+
# `@index_build_cache` Hash that provides O(1) lookups. After building,
|
|
24
|
+
# the cache is cleared.
|
|
25
|
+
#
|
|
26
|
+
# - Index building with Hash lookup: ~0.08 seconds
|
|
27
|
+
# - Speedup: 325× faster
|
|
28
|
+
#
|
|
29
|
+
# === The Proper Fix
|
|
30
|
+
#
|
|
31
|
+
# This tech debt should be resolved by enhancing Lutaml::Model::Collection
|
|
32
|
+
# to support efficient key-based lookups. See the reproduction script at:
|
|
33
|
+
# `dev/lutaml_model_collection_lookup_benchmark.rb`
|
|
34
|
+
#
|
|
35
|
+
# Related issue: https://github.com/lutaml/lutaml-model/issues/XXX
|
|
36
|
+
#
|
|
3
37
|
module IndexMixin
|
|
4
38
|
def self.included(base)
|
|
5
39
|
base.extend(ClassMethods)
|
|
@@ -35,13 +69,17 @@ module Fontist
|
|
|
35
69
|
def reset_cache
|
|
36
70
|
# Delete the index file to force rebuild on next access
|
|
37
71
|
# This is important for tests to ensure clean state
|
|
38
|
-
|
|
72
|
+
FileUtils.rm_f(path)
|
|
39
73
|
end
|
|
40
74
|
end
|
|
41
75
|
|
|
76
|
+
# Build index by loading all formulas from disk.
|
|
77
|
+
# Uses Hash-based cache for O(1) lookups during building.
|
|
42
78
|
def build
|
|
43
|
-
|
|
44
|
-
|
|
79
|
+
with_index_build_cache do
|
|
80
|
+
Formula.all.each do |formula|
|
|
81
|
+
add_formula(formula)
|
|
82
|
+
end
|
|
45
83
|
end
|
|
46
84
|
|
|
47
85
|
to_file
|
|
@@ -49,9 +87,16 @@ module Fontist
|
|
|
49
87
|
self
|
|
50
88
|
end
|
|
51
89
|
|
|
90
|
+
# Build index from pre-loaded formulas.
|
|
91
|
+
# Uses Hash-based cache for O(1) lookups during building.
|
|
92
|
+
#
|
|
93
|
+
# This is the preferred method when formulas are already loaded,
|
|
94
|
+
# as it avoids re-loading from disk.
|
|
52
95
|
def build_with_formulas(formulas)
|
|
53
|
-
|
|
54
|
-
|
|
96
|
+
with_index_build_cache do
|
|
97
|
+
formulas.each do |formula|
|
|
98
|
+
add_formula(formula)
|
|
99
|
+
end
|
|
55
100
|
end
|
|
56
101
|
|
|
57
102
|
to_file
|
|
@@ -73,26 +118,20 @@ module Fontist
|
|
|
73
118
|
|
|
74
119
|
def index_key_for_style(_style)
|
|
75
120
|
raise NotImplementedError,
|
|
76
|
-
"index_key_for_style(style) must be implemented
|
|
121
|
+
"index_key_for_style(style) must be implemented"
|
|
77
122
|
end
|
|
78
123
|
|
|
124
|
+
# Add a font style to the index with O(1) or O(n) lookup.
|
|
125
|
+
#
|
|
126
|
+
# Uses `@index_build_cache` Hash for O(1) lookup during building,
|
|
127
|
+
# falling back to O(n) Array lookup for incremental updates.
|
|
79
128
|
def add_index_formula(style, formula_path)
|
|
80
|
-
key =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
key = normalize_key(key)
|
|
84
|
-
formula_path = Array(formula_path)
|
|
85
|
-
paths = formula_path.map { |p| relative_formula_path(p) }
|
|
129
|
+
key = prepare_index_key(style)
|
|
130
|
+
paths = prepare_formula_paths(formula_path)
|
|
86
131
|
|
|
87
|
-
if
|
|
88
|
-
index_formula(key).formula_path.concat(paths).uniq!
|
|
89
|
-
return
|
|
90
|
-
end
|
|
132
|
+
return if merge_existing_entry?(key, paths)
|
|
91
133
|
|
|
92
|
-
|
|
93
|
-
key: key,
|
|
94
|
-
formula_path: paths,
|
|
95
|
-
)
|
|
134
|
+
create_and_add_entry(key, paths)
|
|
96
135
|
end
|
|
97
136
|
|
|
98
137
|
def load_formulas(key)
|
|
@@ -114,6 +153,56 @@ module Fontist
|
|
|
114
153
|
|
|
115
154
|
private
|
|
116
155
|
|
|
156
|
+
# Yields with a Hash-based lookup cache for O(1) key lookups.
|
|
157
|
+
#
|
|
158
|
+
# This is a performance optimization to avoid O(n²) behavior
|
|
159
|
+
# when building indexes with thousands of entries.
|
|
160
|
+
#
|
|
161
|
+
# @yield [void] Block to execute with cache enabled
|
|
162
|
+
# @return [void]
|
|
163
|
+
def with_index_build_cache
|
|
164
|
+
@index_build_cache = {}
|
|
165
|
+
yield
|
|
166
|
+
ensure
|
|
167
|
+
@index_build_cache = nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def prepare_index_key(style)
|
|
171
|
+
key = index_key_for_style(style)
|
|
172
|
+
raise if key.nil? || key.empty?
|
|
173
|
+
|
|
174
|
+
normalize_key(key)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def prepare_formula_paths(formula_path)
|
|
178
|
+
Array(formula_path).map { |p| relative_formula_path(p) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Attempt to merge paths into existing entry.
|
|
182
|
+
# Returns true if merged, false if no existing entry found.
|
|
183
|
+
def merge_existing_entry?(key, paths)
|
|
184
|
+
existing = find_existing_entry(key)
|
|
185
|
+
return false unless existing
|
|
186
|
+
|
|
187
|
+
existing.formula_path.concat(paths).uniq!
|
|
188
|
+
true
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Find existing entry using cache (O(1)) or array scan (O(n))
|
|
192
|
+
def find_existing_entry(key)
|
|
193
|
+
if @index_build_cache
|
|
194
|
+
@index_build_cache[key]
|
|
195
|
+
else
|
|
196
|
+
index_formula(key)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def create_and_add_entry(key, paths)
|
|
201
|
+
entry = FormulaKeyToPath.new(key: key, formula_path: paths)
|
|
202
|
+
entries << entry
|
|
203
|
+
@index_build_cache[key] = entry if @index_build_cache
|
|
204
|
+
end
|
|
205
|
+
|
|
117
206
|
def index_formula(key)
|
|
118
207
|
Array(entries).detect { |f| normalize_key(f.key) == normalize_key(key) }
|
|
119
208
|
end
|
data/lib/fontist/manifest.rb
CHANGED
|
@@ -79,13 +79,13 @@ location: nil)
|
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
def validate_platform_compatibility!
|
|
82
|
-
formula = Fontist::Formula.
|
|
83
|
-
return if formula.
|
|
84
|
-
return if formula.compatible_with_platform?
|
|
82
|
+
formula = Fontist::Formula.find_many(name)
|
|
83
|
+
return if formula.empty?
|
|
84
|
+
return if formula.any?(&:compatible_with_platform?)
|
|
85
85
|
|
|
86
86
|
raise Fontist::Errors::PlatformMismatchError.new(
|
|
87
87
|
name,
|
|
88
|
-
formula.platforms,
|
|
88
|
+
formula.map(&:platforms).flatten.uniq,
|
|
89
89
|
Fontist::Utils::System.user_os,
|
|
90
90
|
)
|
|
91
91
|
end
|
data/lib/fontist/system_index.rb
CHANGED
|
@@ -682,7 +682,10 @@ spinner_index = nil)
|
|
|
682
682
|
|
|
683
683
|
def parse_font(font_file, path)
|
|
684
684
|
# Skip fonts with incomplete metadata
|
|
685
|
-
|
|
685
|
+
unless font_file.full_name && font_file.family
|
|
686
|
+
warn_incomplete_metadata(path)
|
|
687
|
+
return nil
|
|
688
|
+
end
|
|
686
689
|
|
|
687
690
|
# Get file metadata for caching
|
|
688
691
|
file_size = begin
|
|
@@ -708,6 +711,32 @@ spinner_index = nil)
|
|
|
708
711
|
)
|
|
709
712
|
end
|
|
710
713
|
|
|
714
|
+
def filter_valid_fonts(fonts)
|
|
715
|
+
fonts.select do |font|
|
|
716
|
+
missing_keys = ALLOWED_KEYS.reject { |key| font.send(key) }
|
|
717
|
+
|
|
718
|
+
next true if missing_keys.empty?
|
|
719
|
+
|
|
720
|
+
warn_font_metadata_incomplete(font, missing_keys)
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def warn_font_metadata_incomplete(font, missing_keys)
|
|
725
|
+
Fontist.ui.error(<<~MSG.chomp)
|
|
726
|
+
Skipping font with incomplete metadata: #{font.path}
|
|
727
|
+
Missing attributes: #{missing_keys.join(', ')}.
|
|
728
|
+
This font will not be indexed, but Fontist will continue to work.
|
|
729
|
+
MSG
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def warn_incomplete_metadata(path)
|
|
733
|
+
Fontist.ui.error(<<~MSG.chomp)
|
|
734
|
+
Skipping font with incomplete metadata: #{path}
|
|
735
|
+
Missing attributes: full_name, family_name.
|
|
736
|
+
This font will not be indexed, but Fontist will continue to work.
|
|
737
|
+
MSG
|
|
738
|
+
end
|
|
739
|
+
|
|
711
740
|
def raise_font_index_corrupted(font, missing_keys)
|
|
712
741
|
raise(Errors::FontIndexCorrupted, <<~MSG.chomp)
|
|
713
742
|
Font index is corrupted.
|
data/lib/fontist/utils/cache.rb
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
require_relative "cache"
|
|
2
|
+
require_relative "github_url"
|
|
3
|
+
require_relative "github_client"
|
|
2
4
|
|
|
3
5
|
module Fontist
|
|
4
6
|
module Utils
|
|
@@ -59,16 +61,30 @@ module Fontist
|
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
def download_file
|
|
62
|
-
tries
|
|
64
|
+
@tries ||= 0
|
|
65
|
+
@tries += 1
|
|
63
66
|
print_download_start if @verbose
|
|
64
67
|
do_download_file
|
|
65
68
|
rescue Down::Error => e
|
|
66
|
-
|
|
69
|
+
if @tries < max_retries
|
|
70
|
+
sleep(backoff_time(@tries))
|
|
71
|
+
retry
|
|
72
|
+
end
|
|
67
73
|
|
|
68
74
|
raise Fontist::Errors::InvalidResourceError,
|
|
69
75
|
"Invalid URL: #{@file}. Error: #{e.inspect}."
|
|
70
76
|
end
|
|
71
77
|
|
|
78
|
+
def max_retries
|
|
79
|
+
@max_retries ||= 3
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def backoff_time(attempt)
|
|
83
|
+
# Exponential backoff: 2^attempt seconds, max 30 seconds
|
|
84
|
+
# 1st retry: 2s, 2nd: 4s, 3rd: 8s
|
|
85
|
+
[2**attempt, 30].min
|
|
86
|
+
end
|
|
87
|
+
|
|
72
88
|
def print_download_start
|
|
73
89
|
Fontist.ui.say("Downloading from: #{Paint[url, :cyan]}")
|
|
74
90
|
if @verbose
|
|
@@ -115,8 +131,10 @@ module Fontist
|
|
|
115
131
|
# rubocop:enable Metrics/MethodLength
|
|
116
132
|
|
|
117
133
|
def url
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
@url ||= begin
|
|
135
|
+
raw_url = extract_raw_url
|
|
136
|
+
github_aware_url(raw_url)
|
|
137
|
+
end
|
|
120
138
|
end
|
|
121
139
|
|
|
122
140
|
def headers
|
|
@@ -126,6 +144,22 @@ module Fontist
|
|
|
126
144
|
obj.headers.to_h.map { |k, v| [k.to_s, v] }.to_h || # rubocop:disable Style/HashTransformKeys, Metrics/LineLength
|
|
127
145
|
{}
|
|
128
146
|
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def extract_raw_url
|
|
151
|
+
obj = Helpers.url_object(@file)
|
|
152
|
+
obj.respond_to?(:url) ? obj.url : obj
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def github_aware_url(raw_url)
|
|
156
|
+
parsed = GitHubUrl.parse(raw_url)
|
|
157
|
+
if parsed.matched?
|
|
158
|
+
GitHubClient.authenticated_download_url(parsed)
|
|
159
|
+
else
|
|
160
|
+
raw_url
|
|
161
|
+
end
|
|
162
|
+
end
|
|
129
163
|
end
|
|
130
164
|
|
|
131
165
|
class ProgressBar
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "octokit"
|
|
2
|
+
|
|
3
|
+
module Fontist
|
|
4
|
+
module Utils
|
|
5
|
+
class GitHubClient
|
|
6
|
+
class << self
|
|
7
|
+
def authenticated_download_url(parsed_url)
|
|
8
|
+
return parsed_url.original_url unless parsed_url.matched?
|
|
9
|
+
|
|
10
|
+
client = create_client
|
|
11
|
+
release = fetch_release(client, parsed_url)
|
|
12
|
+
|
|
13
|
+
find_asset_url(release, parsed_url.asset) || parsed_url.original_url
|
|
14
|
+
rescue Octokit::Error => e
|
|
15
|
+
Fontist.ui.say("GitHub API error: #{e.message}. Falling back to direct download.")
|
|
16
|
+
parsed_url.original_url
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def create_client
|
|
22
|
+
if github_token
|
|
23
|
+
Octokit::Client.new(access_token: github_token)
|
|
24
|
+
else
|
|
25
|
+
Octokit::Client.new
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def github_token
|
|
30
|
+
ENV.fetch("GITHUB_TOKEN", nil)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fetch_release(client, parsed_url)
|
|
34
|
+
client.release_for_tag("#{parsed_url.owner}/#{parsed_url.repo}", parsed_url.tag)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def find_asset_url(release, asset_name)
|
|
38
|
+
release.assets.find { |asset| asset.name == asset_name }&.browser_download_url
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Fontist
|
|
2
|
+
module Utils
|
|
3
|
+
class GitHubUrl
|
|
4
|
+
GITHUB_RELEASE_PATTERN =
|
|
5
|
+
%r{^https?://github\.com/(?<owner>[^/]+)/(?<repo>[^/]+)/releases/download/(?<tag>[^/]+)/(?<asset>.+)$}
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def match?(url)
|
|
9
|
+
parse(url).matched?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def parse(url)
|
|
13
|
+
url_string = url.to_s
|
|
14
|
+
match = url_string.match(GITHUB_RELEASE_PATTERN)
|
|
15
|
+
|
|
16
|
+
if match
|
|
17
|
+
ParsedUrl.new(
|
|
18
|
+
owner: match[:owner],
|
|
19
|
+
repo: match[:repo],
|
|
20
|
+
tag: match[:tag],
|
|
21
|
+
asset: match[:asset],
|
|
22
|
+
original_url: url_string
|
|
23
|
+
)
|
|
24
|
+
else
|
|
25
|
+
ParsedUrl.from_non_github_url(url_string)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class ParsedUrl
|
|
31
|
+
attr_reader :owner, :repo, :tag, :asset, :original_url
|
|
32
|
+
|
|
33
|
+
def initialize(owner:, repo:, tag:, asset:, original_url:)
|
|
34
|
+
@owner = owner
|
|
35
|
+
@repo = repo
|
|
36
|
+
@tag = tag
|
|
37
|
+
@asset = asset
|
|
38
|
+
@original_url = original_url
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.from_non_github_url(original_url)
|
|
42
|
+
new(owner: nil, repo: nil, tag: nil, asset: nil, original_url: original_url)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def matched?
|
|
46
|
+
!owner.nil?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/fontist/version.rb
CHANGED
data/lib/fontist.rb
CHANGED
|
@@ -188,9 +188,12 @@ module Fontist
|
|
|
188
188
|
def self.formulas_repo_path_exists!
|
|
189
189
|
return true if Dir.exist?(Fontist.formulas_repo_path.join("Formulas"))
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
191
|
+
# Auto-update formulas repo if it doesn't exist (lazy initialization).
|
|
192
|
+
# This ensures formulas are always discoverable without requiring
|
|
193
|
+
# explicit `fontist update`.
|
|
194
|
+
Formula.update_formulas_repo
|
|
195
|
+
|
|
196
|
+
true
|
|
194
197
|
end
|
|
195
198
|
end
|
|
196
199
|
|