fontico 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.
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Fontico
8
+ # Turns manifest entries into raw SVG. Vendor icons come from the Iconify
9
+ # API in one batched request per provider; first-party icons come off disk.
10
+ class Resolver
11
+ class Error < Fontico::Error; end
12
+
13
+ API = "https://api.iconify.design"
14
+
15
+ # Guards a malformed set; real chains are one or two hops.
16
+ MAX_ALIAS_DEPTH = 8
17
+
18
+ Source = Struct.new(:markup, :width, :height, keyword_init: true)
19
+
20
+ # { "save" => "lucide/save: not found in provider lucide" } — icons this
21
+ # run could not resolve. Filled by #call; see the comment there.
22
+ attr_reader :missing
23
+
24
+ def initialize(manifest, root: Dir.pwd, api: API)
25
+ @manifest = manifest
26
+ @root = root
27
+ @api = api
28
+ @missing = {}
29
+ end
30
+
31
+ # => { "save" => Source, ... } keyed by icon name.
32
+ #
33
+ # One bad name in a manifest of two hundred used to take the whole build
34
+ # down, which meant a typo in an icon nobody had shipped yet blocked
35
+ # everyone. A single icon failing is now recorded in #missing and left
36
+ # out of the result; the caller reports it and builds the rest. Failures
37
+ # that are not per-icon — an unreachable API, a provider that 404s — are
38
+ # still raised, because then nothing would be correct.
39
+ def call(only: nil)
40
+ icons = @manifest.icons
41
+ icons = icons.select { only.include?(_1.name) } if only
42
+
43
+ resolved = {}
44
+ icons.select(&:local?).each do |icon|
45
+ try(icon) { resolved[icon.name] = local(icon) }
46
+ end
47
+
48
+ icons.reject(&:local?).group_by(&:provider).each do |provider, group|
49
+ payload = fetch(provider, group.map(&:slug).uniq.sort)
50
+ group.each { |icon| try(icon) { resolved[icon.name] = remote(icon, payload) } }
51
+ end
52
+
53
+ resolved
54
+ end
55
+
56
+ private
57
+
58
+ def try(icon)
59
+ yield
60
+ rescue Error => e
61
+ @missing[icon.name] = e.message
62
+ nil
63
+ end
64
+
65
+ def local(icon)
66
+ path = File.join(@root, @manifest.local_path, "#{icon.slug}.svg")
67
+ raise Error, "#{icon.source}: no such file #{path}" unless File.exist?(path)
68
+
69
+ Source.new(markup: File.read(path))
70
+ end
71
+
72
+ # One request per provider, not per icon. 30 icons across two providers
73
+ # measured at ~0.9s and 7KB total.
74
+ def fetch(provider, slugs)
75
+ uri = URI("#{@api}/#{provider}.json?icons=#{slugs.join(",")}")
76
+ body = Net::HTTP.get_response(uri).then do |res|
77
+ raise Error, "#{provider}: API returned #{res.code}" unless res.is_a?(Net::HTTPSuccess)
78
+
79
+ res.body
80
+ end
81
+ JSON.parse(body)
82
+ rescue JSON::ParserError, SocketError, Errno::ECONNREFUSED => e
83
+ raise Error, "#{provider}: could not reach #{@api} (#{e.class}). " \
84
+ "Run with a populated icons.lock to build offline."
85
+ end
86
+
87
+ def remote(icon, payload)
88
+ data, transform = lookup(icon, payload)
89
+ raise Error, "#{icon.source}: not found in provider #{icon.provider}" if data.nil?
90
+
91
+ # Per-icon dimensions override the set default; fa6-solid ships 512
92
+ # sets with 576 icons inside them.
93
+ width = data["width"] || payload["width"]
94
+ height = data["height"] || payload["height"]
95
+
96
+ Source.new(
97
+ markup: apply(transform, data.fetch("body"), width, height),
98
+ width: width,
99
+ height: height
100
+ )
101
+ end
102
+
103
+ # Iconify keeps renames and mirrored variants out of "icons" and in
104
+ # "aliases", pointing at a parent that may itself be an alias. Without
105
+ # this, `lucide/fingerprint` — an alias since the icon was renamed to
106
+ # fingerprint-pattern — resolves to nothing and the build dies.
107
+ def lookup(icon, payload)
108
+ slug = icon.slug
109
+ transform = {}
110
+ seen = []
111
+
112
+ MAX_ALIAS_DEPTH.times do
113
+ return [payload.dig("icons", slug), transform] if payload.dig("icons", slug)
114
+
115
+ entry = payload.dig("aliases", slug)
116
+ return [nil, transform] if entry.nil?
117
+
118
+ # A transform is expressed relative to the parent, so an alias chain
119
+ # composes outwards: rotation adds, each flip toggles.
120
+ transform[:rotate] = (transform[:rotate].to_i + entry["rotate"].to_i) % 4
121
+ transform[:h_flip] = transform[:h_flip] ^ true if entry["hFlip"]
122
+ transform[:v_flip] = transform[:v_flip] ^ true if entry["vFlip"]
123
+
124
+ seen << slug
125
+ slug = entry["parent"]
126
+ raise Error, "#{icon.source}: alias cycle #{(seen << slug).join(" -> ")}" if seen.include?(slug)
127
+ end
128
+
129
+ raise Error, "#{icon.source}: alias chain deeper than #{MAX_ALIAS_DEPTH} in #{icon.provider}"
130
+ end
131
+
132
+ # Flip about the box centre, then rotate about it in quarter turns —
133
+ # the order Iconify defines. Emitted as one wrapping <g> so the body
134
+ # itself is untouched and still normalises like any other.
135
+ def apply(transform, body, width, height)
136
+ return body if transform.empty? || transform.values.none? { _1 == true || _1.to_i.positive? }
137
+
138
+ w = (width || 24).to_f
139
+ h = (height || 24).to_f
140
+ ops = []
141
+ ops << "rotate(#{transform[:rotate] * 90} #{w / 2} #{h / 2})" if transform[:rotate].to_i.positive?
142
+ ops << "translate(#{w} 0) scale(-1 1)" if transform[:h_flip]
143
+ ops << "translate(0 #{h}) scale(1 -1)" if transform[:v_flip]
144
+
145
+ %(<g transform="#{ops.join(" ")}">#{body}</g>)
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontico
4
+ VERSION = "0.1.0"
5
+ end
data/lib/fontico.rb ADDED
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require_relative "fontico/version"
5
+
6
+ module Fontico
7
+ class Error < StandardError; end
8
+
9
+ autoload :Icon, "fontico/icon"
10
+ autoload :Manifest, "fontico/manifest"
11
+ autoload :Preprocessor, "fontico/preprocessor"
12
+ autoload :Resolver, "fontico/resolver"
13
+ autoload :Lockfile, "fontico/lockfile"
14
+ autoload :Builder, "fontico/builder"
15
+ autoload :Helper, "fontico/helper"
16
+ autoload :NodeRunner, "fontico/node_runner"
17
+ autoload :ProviderFonts, "fontico/provider_fonts"
18
+ autoload :Outliner, "fontico/outliner"
19
+ # fontico/prawn is opt-in: `require "fontico/prawn"`, so the gem never
20
+ # loads Prawn for apps that only build a sprite.
21
+
22
+ module Emitters
23
+ autoload :Sprite, "fontico/emitters/sprite"
24
+ autoload :Font, "fontico/emitters/font"
25
+ autoload :Stylesheet, "fontico/emitters/stylesheet"
26
+ end
27
+
28
+ class << self
29
+ attr_writer :manifest_path, :root, :output_dir, :inline_sprite, :css_class
30
+
31
+ # Base class on every emitted <svg>; also the stylesheet's selector.
32
+ def css_class = @css_class ||= "ico"
33
+
34
+ def root = @root ||= Dir.pwd
35
+ def output_dir = @output_dir ||= "app/assets/builds"
36
+ def manifest_path = @manifest_path ||= File.join(root, "icons.yml")
37
+ def manifest = @manifest ||= Manifest.load(manifest_path)
38
+ def sprite_file = File.join(root, output_dir, "icons.svg")
39
+
40
+ # Inline mode embeds <symbol> definitions in the layout instead of
41
+ # referencing an external file — required when assets are served from a
42
+ # CDN, where cross-origin <use href> silently renders nothing.
43
+ def inline_sprite? = !!@inline_sprite
44
+
45
+ def build(**opts) = Builder.new(manifest, root: root, output: output_dir, **opts).call
46
+
47
+ # The dev reloader's build, and the only caller that records what went
48
+ # wrong. Building stays forgiving on purpose — one typo must not stop the
49
+ # other 199 icons — so nothing raises here; the complaint is held until
50
+ # someone actually draws an icon. See #check!.
51
+ def rebuild!
52
+ @build_error = nil
53
+ @missing_icons = build.missing || {}
54
+ rescue StandardError => e
55
+ @build_error = e
56
+ @missing_icons = {}
57
+ end
58
+
59
+ # Why the last rebuild could not deliver: the exception it died on, and
60
+ # the icons it left out of the sprite.
61
+ attr_reader :build_error
62
+
63
+ def missing_icons = @missing_icons ||= {}
64
+
65
+ # Raised at the call site, because that is the only place with anything
66
+ # useful to say. An icon left out of the sprite still resolves through the
67
+ # manifest and renders a perfectly valid <use> at a symbol that isn't
68
+ # there — an invisible empty box, on a page that 200s. Silence is the one
69
+ # outcome worse than a stack trace.
70
+ #
71
+ # Free in production: only the dev reloader ever fills these in.
72
+ def check!(name = nil)
73
+ raise Error, "icons.yml did not build: #{@build_error.message}" if @build_error
74
+
75
+ reason = missing_icons[name]
76
+ raise Error, "icon #{name.inspect} was left out of the sprite: #{reason}" if reason
77
+ end
78
+
79
+ def lockfile = Lockfile.new(File.join(root, "icons.lock"))
80
+
81
+ def font_file = File.join(root, output_dir, "icons.ttf")
82
+
83
+ # The character to print when drawing this icon from the font — for Prawn,
84
+ # where you write the glyph literally. Pinned append-only in icons.lock so
85
+ # a reference in Ruby source never goes stale.
86
+ def codepoint(name)
87
+ cp = lockfile.codepoint_for(name.to_s)
88
+ raise Error, "no icon named #{name.inspect}; run rake fontico:build" if cp.nil?
89
+
90
+ cp
91
+ end
92
+
93
+ def glyph(name) = [codepoint(name)].pack("U")
94
+ def reset! = (@manifest = @build_error = @missing_icons = nil)
95
+
96
+ def configure = yield(self)
97
+ end
98
+ end
99
+
100
+ require "fontico/railtie" if defined?(::Rails::Railtie)
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Only when someone is watching: a colour escape in a CI log or a piped file
4
+ # is noise, and the message reads fine without it.
5
+ def fontico_red(text) = $stdout.tty? ? "\e[31m#{text}\e[0m" : text
6
+
7
+ namespace :fontico do
8
+ desc "Build icon artifacts from icons.yml"
9
+ task :build do
10
+ require "fontico"
11
+ report = Fontico.build
12
+
13
+ puts "fontico: #{report.written.size} artifact(s)"
14
+ report.written.each { puts " #{_1}" }
15
+ puts " fetched #{report.fetched.size}, cached #{report.cached.size}"
16
+
17
+ # Loud, but not fatal: one bad name should not stop the other 199 icons
18
+ # from building. The artifacts simply come out without it.
19
+ if report.missing&.any?
20
+ puts
21
+ puts fontico_red("fontico: #{report.missing.size} icon(s) could not be resolved and were left out:")
22
+ report.missing.each { |name, reason| puts fontico_red(" #{name}: #{reason}") }
23
+ puts fontico_red(" fix the entry in icons.yml, then run rake fontico:build again")
24
+ puts
25
+ end
26
+
27
+ report.skipped.each do |target, names|
28
+ puts " #{target}: skipped #{names.size} multicolor icon(s): #{names.join(", ")}" if names.any?
29
+ end
30
+ puts " pending target(s): #{report.pending.join(", ")}" if report.pending.any?
31
+
32
+ if report.warnings.any?
33
+ puts "\nfontico: #{report.warnings.size} icon(s) need fixing at the source:"
34
+ report.warnings.each { |name, list| puts " #{name}: #{list.join("; ")}" }
35
+ puts " see docs/icon-authoring.html"
36
+ end
37
+ end
38
+
39
+ desc "Re-fetch every icon, ignoring icons.lock"
40
+ task :update do
41
+ require "fontico"
42
+ File.delete(File.join(Fontico.root, "icons.lock")) if File.exist?(File.join(Fontico.root, "icons.lock"))
43
+ Rake::Task["fontico:build"].invoke
44
+ end
45
+ end
46
+
47
+ # Deploys just work: Propshaft serves whatever is in app/assets/builds, and
48
+ # that directory is gitignored in a stock Rails app, so the artifacts have to
49
+ # be regenerated during precompile rather than committed.
50
+ #
51
+ # Same hook jsbundling-rails uses for javascript:build.
52
+ if Rake::Task.task_defined?("assets:precompile")
53
+ Rake::Task["assets:precompile"].enhance(["fontico:build"])
54
+ end
55
+
56
+ if Rake::Task.task_defined?("assets:clobber")
57
+ Rake::Task["assets:clobber"].enhance(["fontico:clobber"])
58
+ end
59
+
60
+ namespace :fontico do
61
+ desc "Remove generated icon artifacts (icons.lock is kept: it is source)"
62
+ task :clobber do
63
+ require "fontico"
64
+ %w[icons.svg icons.css icons.ttf].each do |name|
65
+ path = File.join(Fontico.root, Fontico.output_dir, name)
66
+ File.delete(path) if File.exist?(path)
67
+ end
68
+ end
69
+ end
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fontico
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - nofxx
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rexml
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: prawn
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.5'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.5'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rubocop
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.79'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.79'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop-performance
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.25'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.25'
68
+ description: Declare every icon in your app in one manifest — from Lucide, Material
69
+ Symbols, any Iconify set, or your own SVG folder — under names you choose. fontico
70
+ resolves, normalises and merges them into a single build artifact, so templates
71
+ never name a vendor.
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - LICENSE.txt
77
+ - README.md
78
+ - docs/expand-check.png
79
+ - docs/icon-authoring.html
80
+ - docs/lucide-glyphs-extracted.png
81
+ - docs/lucide-ttf-glyph.png
82
+ - docs/rails-preview.png
83
+ - docs/sprite-sheet.png
84
+ - docs/stroke-vs-fill.png
85
+ - docs/ttf-glyphs.png
86
+ - lib/fontico.rb
87
+ - lib/fontico/builder.rb
88
+ - lib/fontico/emitters/font.rb
89
+ - lib/fontico/emitters/sprite.rb
90
+ - lib/fontico/emitters/stylesheet.rb
91
+ - lib/fontico/helper.rb
92
+ - lib/fontico/icon.rb
93
+ - lib/fontico/lockfile.rb
94
+ - lib/fontico/manifest.rb
95
+ - lib/fontico/node/build_font.mjs
96
+ - lib/fontico/node/extract_glyphs.mjs
97
+ - lib/fontico/node/package.json
98
+ - lib/fontico/node_runner.rb
99
+ - lib/fontico/outliner.rb
100
+ - lib/fontico/prawn.rb
101
+ - lib/fontico/preprocessor.rb
102
+ - lib/fontico/provider_fonts.rb
103
+ - lib/fontico/railtie.rb
104
+ - lib/fontico/resolver.rb
105
+ - lib/fontico/version.rb
106
+ - lib/tasks/fontico.rake
107
+ homepage: https://github.com/fireho/fontico
108
+ licenses:
109
+ - MIT
110
+ metadata:
111
+ source_code_uri: https://github.com/fireho/fontico
112
+ bug_tracker_uri: https://github.com/fireho/fontico/issues
113
+ changelog_uri: https://github.com/fireho/fontico/blob/main/CHANGELOG.md
114
+ rubygems_mfa_required: 'true'
115
+ rdoc_options: []
116
+ require_paths:
117
+ - lib
118
+ required_ruby_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '3.1'
123
+ required_rubygems_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0'
128
+ requirements: []
129
+ rubygems_version: 3.6.7
130
+ specification_version: 4
131
+ summary: Name icons by intent. Source them from anywhere. Ship one artifact.
132
+ test_files: []