fontico 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e650a7b2e4dd27bd57a0318811c9e5557b229c7b21151fd40b670f6d513532b1
4
- data.tar.gz: 0a59aa8f304d339c3d5a151c3359473c585ef1c9fe438252d24147a19462c937
3
+ metadata.gz: 3ad5f56ad9c1523dbd17d2cdb07da3a2d6aac315008ebd08ba84551d125083c0
4
+ data.tar.gz: 207804df603f81d1da9117d5b081a73dbcf649bf882dbeb43fdec33678fcacb4
5
5
  SHA512:
6
- metadata.gz: 07f21c106e47d7dbc23e73a0c8d091d7b9284bc6a32b0d2fde04a7fef5fc9d11e3b650a9e223dfd204214511d35ae454ffa67602e2e0e48426295456cd72d682
7
- data.tar.gz: fccdc71296e585a335f9a7539234ced2cf6fe1e8f51721aa288825cb840e9a3866db1e31cd1c947e2293383828068dcd112263d31cfeffbfa2548c04e1be913a
6
+ metadata.gz: 4a4bf9595c5257803d26ff2c79607d0a44b48b0c4bc7b1584ce9a71e6e61f18fd71447884eba9d9e48d296a5aec6c42ad6973b7c82e197443be02c6b1032756e
7
+ data.tar.gz: 3132cfd1d3a9fa3997a23ed450fa749416aac364414b883e5c65e6995b7708cbbb4155ed672de376c0089543ea3b36167cc1d93deb055e402529ca705685f030
data/README.md CHANGED
@@ -1,6 +1,20 @@
1
- # fontico
1
+ <div align="center">
2
2
 
3
- Name icons by intent. Source them from anywhere. Ship one artifact.
3
+ ```
4
+ ╔═╗╔═╗╔╗╔╔╦╗╦╔═╗╔═╗
5
+ ╠╣ ║ ║║║║ ║ ║║ ║ ║
6
+ ╚ ╚═╝╝╚╝ ╩ ╩╚═╝╚═╝
7
+ ```
8
+
9
+ **Name icons by intent. Source them from anywhere. Ship one artifact.**
10
+
11
+ [![Gem](https://img.shields.io/gem/v/fontico)](https://rubygems.org/gems/fontico)
12
+ [![CI](https://github.com/fireho/fontico/actions/workflows/ci.yml/badge.svg)](https://github.com/fireho/fontico/actions/workflows/ci.yml)
13
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE.txt)
14
+
15
+ </div>
16
+
17
+ ---
4
18
 
5
19
  ```erb
6
20
  <%= icon "save" %> <%# Lucide %>
@@ -11,6 +25,30 @@ Name icons by intent. Source them from anywhere. Ship one artifact.
11
25
  Three providers, one call. Templates never name a vendor, so re-skinning the
12
26
  app — or surviving an upstream rename — is a diff in one file.
13
27
 
28
+ | | |
29
+ | --- | --- |
30
+ | **353,000 icons** | any of [Iconify's 238 sets](https://icon-sets.iconify.design/), plus your own folder |
31
+ | **One request** | a single sprite, 2.3KB brotli, not render-blocking |
32
+ | **361ms cold, 2ms warm** | measured on 35 icons across two remote providers and five local files |
33
+ | **No Node, no npm** | the sprite target is pure Ruby; deploys rebuild offline from the lockfile |
34
+ | **Also PDFs** | a real TTF for Prawn, with pinned codepoints |
35
+
36
+ ## Install
37
+
38
+ ```ruby
39
+ # Gemfile
40
+ gem "fontico"
41
+ ```
42
+
43
+ ```bash
44
+ bundle install
45
+ $EDITOR icons.yml # the manifest, below
46
+ rake fontico:build
47
+ ```
48
+
49
+ No JS bundle, no `package.json`, no importmap pin. Artifacts land in
50
+ `app/assets/builds/`, which Propshaft already serves.
51
+
14
52
  ## The manifest
15
53
 
16
54
  `icons.yml`, one line per icon:
@@ -35,26 +73,25 @@ icons:
35
73
  ```
36
74
 
37
75
  Like pokemon, you gotta catch 'em all.
38
- Any of [Iconify's 200k+ icons](https://icon-sets.iconify.design/) work as a
39
- provider prefix. Your own SVGs go in `app/assets/icons/`, filename as slug.
76
+ Any of [Iconify's sets](https://icon-sets.iconify.design/) work as a provider
77
+ prefix. Your own SVGs go in `app/assets/icons/`, filename as slug.
78
+
79
+ A Rails engine that ships `config/icons.yml` (or `icons.yml` at its root) is
80
+ merged under the app's file, the same way `config/locales/` layers. Later
81
+ wins a name — the app always has the last word. Nested groups merge, so an
82
+ engine can ship `auth.google` and the app can add `auth.apple` without
83
+ copying the rest. `rake fontico:build` reads the same list.
40
84
 
41
85
  ## Build
42
86
 
43
87
  ```bash
44
88
  rake fontico:build # resolve, normalise, emit
45
- rake fontico:update # re-fetch, ignoring icons.lock
89
+ rake fontico:update # re-fetch bodies; codepoints stay pinned
46
90
  ```
47
91
 
48
- Artifacts land in `app/assets/builds/`, which Propshaft serves automatically
49
- no manifest, no precompile list. `rake assets:precompile` is hooked, so deploys
50
- need no extra step.
51
-
52
- That directory is gitignored in a stock Rails app, so nothing is committed from
53
- it. **`icons.lock` is the thing you commit**: it holds every normalised body,
54
- so a deploy rebuilds the sprite from it in milliseconds with no network access
55
- and no Node.
92
+ `rake assets:precompile` is hooked, so deploys need no extra step.
56
93
 
57
- In development you rarely type either one. Saving `icons.yml` — or a local
94
+ **In development you rarely type either one.** Saving `icons.yml` — or a local
58
95
  SVG — rebuilds the artifacts and drops the cached manifest, so the icon is live
59
96
  on the next request: no rake, no restart. A save that only reshuffles known
60
97
  icons costs a couple of milliseconds; a brand-new one pays its provider fetch
@@ -73,13 +110,14 @@ there — is named in red and left out; the rest of the manifest still builds.
73
110
  It keeps its codepoint reserved, so fixing the entry and rebuilding brings it
74
111
  back with the same glyph. A provider that is unreachable is still fatal.
75
112
 
76
- Measured on 35 icons across two remote providers and five local files:
77
- **361ms cold, 2ms warm.** Vendor icons are fetched in one batched request per
78
- provider — not one per icon.
113
+ Vendor icons are fetched in one batched request per provider not one per
114
+ icon.
79
115
 
80
116
  ## Why there is a lockfile
81
117
 
82
- `icons.lock` pins two things that must not drift:
118
+ `app/assets/builds/` is gitignored in a stock Rails app, so nothing is
119
+ committed from it. **`icons.lock` is the thing you commit.** It pins two things
120
+ that must not drift:
83
121
 
84
122
  - **Codepoints**, append-only. Adding an icon must not renumber the others, or
85
123
  every glyph in a built font moves and the committed artifact churns
@@ -88,7 +126,8 @@ provider — not one per icon.
88
126
  Iconify API serves *latest*; without this an icon can silently change shape
89
127
  between two builds of the same manifest.
90
128
 
91
- Commit it.
129
+ A deploy rebuilds the sprite from it in milliseconds, with no network access
130
+ and no Node. Commit it.
92
131
 
93
132
  ## What happens to your SVGs
94
133
 
@@ -204,12 +243,27 @@ build names each one it dropped.
204
243
  Font targets need Node, installed on demand into `~/.cache/fontico`. A manifest
205
244
  with `targets: [sprite]` never touches it and stays pure Ruby.
206
245
 
246
+ ## How it compares
247
+
248
+ | | vendor gems (`lucide-rails`, `heroicon`) | `iconify-icon` web component | fontico |
249
+ | --- | --- | --- | --- |
250
+ | Icon sets | one, per gem | 238 | 238, plus your own folder |
251
+ | Where the SVG comes from | inlined per render | fetched at runtime, per client | one build-time sprite |
252
+ | Requests | 0, but repeated in every response body | 1+ per icon, per visitor | 1, cached |
253
+ | JS required | no | yes | no |
254
+ | Rename-proof templates | no — `lucide_icon "pencil"` | no — `icon="lucide:pencil"` | yes — `icon "edit"` |
255
+ | Offline / reproducible build | yes | no | yes, via `icons.lock` |
256
+ | PDF | no | no | yes, TTF for Prawn |
257
+
207
258
  ## Status
208
259
 
209
260
  - ✅ Manifest, resolver, preprocessor, lockfile, sprite emitter, Rails helper
210
261
  - ✅ TTF emitter with glyph extraction, codepoint API, Prawn helpers
211
262
  - ⏳ `woff2` — declared targets are skipped with a notice
263
+ - ⏳ `variant:` — accepted and ignored by the helper; see [TODO.md](TODO.md)
212
264
 
213
265
  ## License
214
266
 
215
- MIT
267
+ MIT.
268
+ Icons keep their upstream licenses:
269
+ `icons.lock` records what you declared and shipped.
@@ -12,11 +12,13 @@ module Fontico
12
12
  Report = Struct.new(:written, :warnings, :skipped, :cached, :fetched, :pending,
13
13
  :missing, keyword_init: true)
14
14
 
15
- def initialize(manifest, root: Dir.pwd, output: "app/assets/builds", offline: false)
15
+ def initialize(manifest, root: Dir.pwd, output: "app/assets/builds",
16
+ offline: false, force: false)
16
17
  @manifest = manifest
17
18
  @root = root
18
19
  @output = File.join(root, output)
19
20
  @offline = offline
21
+ @force = force
20
22
  @lock = Lockfile.new(File.join(root, "icons.lock"))
21
23
  end
22
24
 
@@ -25,11 +27,23 @@ module Fontico
25
27
  cached, fetched = [], []
26
28
  missing = {}
27
29
 
28
- stale = @manifest.icons.reject { @lock.fresh?(_1.name, _1.source) }
30
+ # The lock caches Iconify bodies so deploys run offline. Local files
31
+ # are already on disk: treating them as fresh meant editing logo.svg
32
+ # rebuilt nothing. Force is `rake fontico:update` — re-fetch remotes,
33
+ # keep the append-only codepoints.
34
+ stale = @manifest.icons.select { _1.local? || @force || !@lock.fresh?(_1.name, _1.source) }
29
35
  cached = @manifest.icons.map(&:name) - stale.map(&:name)
30
36
 
31
37
  unless stale.empty?
32
- raise Error, "icons.lock is missing #{stale.size} icon(s) and --offline was given" if @offline
38
+ remote_stale = stale.reject(&:local?)
39
+ # Local files do not need the network. Offline is a hard fail only
40
+ # when a remote body has to come off the wire — which under force is
41
+ # every remote, lock or no lock. Saying "missing" there would be a
42
+ # lie: the bodies are present, force is what made them stale.
43
+ if @offline && remote_stale.any?
44
+ reason = @force ? "re-fetch #{remote_stale.size} icon(s)" : "resolve #{remote_stale.size} icon(s) missing from icons.lock"
45
+ raise Error, "cannot #{reason} and --offline was given"
46
+ end
33
47
 
34
48
  resolver = Resolver.new(@manifest, root: @root)
35
49
  sources = resolver.call(only: stale.map(&:name))
@@ -43,9 +57,16 @@ module Fontico
43
57
 
44
58
  pre = Preprocessor.new(icon, size: @manifest.size)
45
59
  .call(src.markup, width: src.width, height: src.height)
60
+ # Locals are re-read every build, so "stale" does not mean changed.
61
+ # Compare the stored digest to keep an untouched logo.svg counted as
62
+ # cached rather than reported as a fetch that never happened. A
63
+ # remote is judged by the wire, not the digest: under force it was
64
+ # genuinely re-fetched even when it came back byte-identical.
65
+ before = @lock.entry(icon.name)&.fetch("digest", nil)
46
66
  @lock.store(icon.name, source: icon.source, body: pre.body,
47
67
  multicolor: pre.multicolor, warnings: pre.warnings)
48
- fetched << icon.name
68
+ unchanged = icon.local? && @lock.entry(icon.name)["digest"] == before
69
+ (unchanged ? cached : fetched) << icon.name
49
70
  end
50
71
  end
51
72
 
@@ -26,9 +26,16 @@ module Fontico
26
26
  outlines = @outliner.outlines(@build, size: @manifest.size)
27
27
 
28
28
  glyphs = @build.map do |icon, body|
29
- { name: icon.key,
30
- codepoint: @lock.codepoint_for(icon.name),
31
- svg: document(outlines[icon.name], body) }
29
+ # Never fall back to allocating here. The builder has already
30
+ # save!d the lock by the time it emits, so a codepoint minted now
31
+ # would never reach disk: the font would ship a glyph that
32
+ # Fontico.glyph cannot name. A nil is worse still — it reaches
33
+ # String.fromCodePoint as null and maps the glyph to U+0000
34
+ # without complaint.
35
+ cp = @lock.codepoint_for(icon.name)
36
+ raise Fontico::Error, "#{icon.name} has no codepoint in icons.lock" if cp.nil?
37
+
38
+ { name: icon.key, codepoint: cp, svg: document(outlines[icon.name], body) }
32
39
  end
33
40
 
34
41
  result = @outliner.instance_variable_get(:@runner)
@@ -29,7 +29,13 @@ module Fontico
29
29
  @entries = data["icons"] || {}
30
30
  end
31
31
 
32
- def codepoint_for(name)
32
+ # Lookup only. A missing name is nil — callers that draw a glyph must
33
+ # not invent a codepoint, or a typo silently becomes a .notdef.
34
+ def codepoint_for(name) = @codepoints[name]
35
+
36
+ # Append-only reservation. store is the production caller; tests use it
37
+ # to pin order without going through a full build.
38
+ def allocate(name)
33
39
  @codepoints[name] ||= next_free
34
40
  end
35
41
 
@@ -43,7 +49,7 @@ module Fontico
43
49
  "warnings" => warnings,
44
50
  "body" => body
45
51
  }
46
- codepoint_for(name)
52
+ allocate(name)
47
53
  end
48
54
 
49
55
  # Names present in the lock but absent from the manifest keep their
@@ -13,7 +13,28 @@ module Fontico
13
13
 
14
14
  attr_reader :path, :defaults, :providers, :targets, :icons
15
15
 
16
- def self.load(path) = new(YAML.safe_load_file(path), path: path)
16
+ def self.load(*paths)
17
+ paths = paths.flatten.compact.select { File.file?(_1) }
18
+ raise Errno::ENOENT, "icons.yml" if paths.empty?
19
+
20
+ data = paths.map { YAML.safe_load_file(_1) || {} }.reduce { |a, b| merge_data(a, b) }
21
+ new(data, path: paths.last)
22
+ end
23
+
24
+ # Later overlay wins a leaf; nested groups merge so an engine can ship
25
+ # `auth.google` and the app can add `auth.apple` without copying.
26
+ def self.merge_data(base, overlay)
27
+ {
28
+ "defaults" => (base["defaults"] || {}).merge(overlay["defaults"] || {}),
29
+ "targets" => overlay["targets"] || base["targets"] || ["sprite"],
30
+ "providers" => (base["providers"] || {}).merge(overlay["providers"] || {}),
31
+ "icons" => deep_merge(base["icons"] || {}, overlay["icons"] || {})
32
+ }
33
+ end
34
+
35
+ def self.deep_merge(left, right)
36
+ left.merge(right) { |_, a, b| a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b }
37
+ end
17
38
 
18
39
  def initialize(data, path: nil)
19
40
  @path = path
@@ -12,6 +12,12 @@ module Fontico
12
12
  Fontico.root = Rails.root.to_s
13
13
  end
14
14
 
15
+ # Engines' config/icons.yml under the app's icons.yml. After root: the
16
+ # app file is Rails.root/icons.yml, not whatever Dir.pwd was at require.
17
+ initializer "fontico.load_path", after: "fontico.root" do |app|
18
+ Fontico.load_path = Fontico.discover(app)
19
+ end
20
+
15
21
  # Saving icons.yml — or a local SVG — rebuilds the artifacts and drops the
16
22
  # memoized manifest, so a new icon is live on the next request with no
17
23
  # restart and no rake. That memo is the only thing that ever needed one:
@@ -20,7 +26,7 @@ module Fontico
20
26
  initializer "fontico.reloader" do |app|
21
27
  next unless app.config.enable_reloading
22
28
 
23
- watcher = app.config.file_watcher.new([Fontico.manifest_path], Railtie.local_dirs) do
29
+ watcher = app.config.file_watcher.new(Fontico.load_path, Railtie.local_dirs) do
24
30
  Fontico.reset!
25
31
  Fontico.rebuild!
26
32
  # Recorded rather than raised: a save that half-breaks the manifest
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontico
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/fontico.rb CHANGED
@@ -34,9 +34,35 @@ module Fontico
34
34
  def root = @root ||= Dir.pwd
35
35
  def output_dir = @output_dir ||= "app/assets/builds"
36
36
  def manifest_path = @manifest_path ||= File.join(root, "icons.yml")
37
- def manifest = @manifest ||= Manifest.load(manifest_path)
37
+ # Same shape as I18n.load_path: engines first, the app last, later wins
38
+ # a name. Unset, it is just the app's icons.yml (non-Rails, tests).
39
+ def load_path = @load_path || [manifest_path].select { File.file?(_1) }
40
+
41
+ def load_path=(paths)
42
+ @load_path = paths.nil? ? nil : Array(paths)
43
+ reset!
44
+ end
45
+
46
+ def manifest = @manifest ||= Manifest.load(*load_path)
38
47
  def sprite_file = File.join(root, output_dir, "icons.svg")
39
48
 
49
+ # Rails engines that ship config/icons.yml (or icons.yml at the gem
50
+ # root), then the app's file. The app is last on purpose.
51
+ def discover(app)
52
+ files = []
53
+ app.railties.each do |rt|
54
+ next unless rt.is_a?(Rails::Engine)
55
+ next if rt.is_a?(Rails::Application)
56
+
57
+ root = rt.root
58
+ found = %w[config/icons.yml icons.yml].map { root.join(_1) }.find { File.file?(_1.to_s) }
59
+ files << found.to_s if found
60
+ end
61
+ # manifest_path goes in even when absent: the dev watcher has to notice
62
+ # an icons.yml created after boot. Manifest.load skips what isn't there.
63
+ (files.select { File.file?(_1) } << manifest_path).uniq
64
+ end
65
+
40
66
  # Inline mode embeds <symbol> definitions in the layout instead of
41
67
  # referencing an external file — required when assets are served from a
42
68
  # CDN, where cross-origin <use href> silently renders nothing.
@@ -85,7 +111,7 @@ module Fontico
85
111
  # a reference in Ruby source never goes stale.
86
112
  def codepoint(name)
87
113
  cp = lockfile.codepoint_for(name.to_s)
88
- raise Error, "no icon named #{name.inspect}; run rake fontico:build" if cp.nil?
114
+ raise Error, "no icon named #{name.inspect} in icons.lock" if cp.nil?
89
115
 
90
116
  cp
91
117
  end
@@ -4,43 +4,52 @@
4
4
  # is noise, and the message reads fine without it.
5
5
  def fontico_red(text) = $stdout.tty? ? "\e[31m#{text}\e[0m" : text
6
6
 
7
- namespace :fontico do
8
- desc "Build icon artifacts from icons.yml"
9
- task :build do
10
- require "fontico"
11
- report = Fontico.build
7
+ def fontico_print_report(report)
8
+ puts "fontico: #{report.written.size} artifact(s)"
9
+ report.written.each { puts " #{_1}" }
10
+ puts " fetched #{report.fetched.size}, cached #{report.cached.size}"
12
11
 
13
- puts "fontico: #{report.written.size} artifact(s)"
14
- report.written.each { puts " #{_1}" }
15
- puts " fetched #{report.fetched.size}, cached #{report.cached.size}"
12
+ # Loud, but not fatal: one bad name should not stop the other 199 icons
13
+ # from building. The artifacts simply come out without it.
14
+ if report.missing&.any?
15
+ puts
16
+ puts fontico_red("fontico: #{report.missing.size} icon(s) could not be resolved and were left out:")
17
+ report.missing.each { |name, reason| puts fontico_red(" #{name}: #{reason}") }
18
+ puts fontico_red(" fix the entry in icons.yml, then run rake fontico:build again")
19
+ puts
20
+ end
16
21
 
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
22
+ report.skipped.each do |target, names|
23
+ puts " #{target}: skipped #{names.size} multicolor icon(s): #{names.join(", ")}" if names.any?
24
+ end
25
+ puts " pending target(s): #{report.pending.join(", ")}" if report.pending.any?
26
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?
27
+ if report.warnings.any?
28
+ puts "\nfontico: #{report.warnings.size} icon(s) need fixing at the source:"
29
+ report.warnings.each { |name, list| puts " #{name}: #{list.join("; ")}" }
30
+ puts " see docs/icon-authoring.html"
31
+ end
32
+ end
31
33
 
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
34
+ def fontico_rails_load_path!
35
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
36
+
37
+ Fontico.load_path = Fontico.discover(Rails.application)
38
+ end
39
+
40
+ namespace :fontico do
41
+ desc "Build icon artifacts from icons.yml"
42
+ task :build do
43
+ require "fontico"
44
+ fontico_rails_load_path!
45
+ fontico_print_report(Fontico.build)
37
46
  end
38
47
 
39
- desc "Re-fetch every icon, ignoring icons.lock"
48
+ desc "Re-fetch every icon from its provider. Codepoints stay pinned."
40
49
  task :update do
41
50
  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
51
+ fontico_rails_load_path!
52
+ fontico_print_report(Fontico.build(force: true))
44
53
  end
45
54
  end
46
55
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontico
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - nofxx
@@ -126,7 +126,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
126
126
  - !ruby/object:Gem::Version
127
127
  version: '0'
128
128
  requirements: []
129
- rubygems_version: 3.6.7
129
+ rubygems_version: 3.6.9
130
130
  specification_version: 4
131
131
  summary: Name icons by intent. Source them from anywhere. Ship one artifact.
132
132
  test_files: []