github-polyglot 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f117581b34a8275e8eeb3f1a08b0fb91bf9c00b2b58f3d13b97976d3bbeeb78c
4
+ data.tar.gz: 07b3a83f37124c1f46a5d4ef5be17d576276ed4ec12b55374473eea7fae60624
5
+ SHA512:
6
+ metadata.gz: ceb3101add8014191e60d18d3adfc1cf894540ea95d5d8f548140699bbe789c8b2495976fda33ccc16a1c5d745561c825ffeba962b7c6d97e4f47f61a4e731c3
7
+ data.tar.gz: 473fb8f6d833d5b8c54051a27ceb98a79362c99d3ddbb01766971342eff082d774212d0d8532c573fa2717b5c90d7b23abd19a277ea860006d58d0232720443b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Spenser Black
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.
data/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # github-polyglot
2
+
3
+ [![CI](https://github.com/spenserblack/github-polyglot/actions/workflows/ci.yml/badge.svg)](https://github.com/spenserblack/github-polyglot/actions/workflows/ci.yml)
4
+
5
+ Linguist, but for the user instead of the repository.
6
+
7
+ This scans all of the repositories that the user has and compiles the language stats.
8
+ The stats can be output as...
9
+
10
+ - A human-readable terminal output
11
+ - JSON
12
+ - An SVG file resembling the language bar
13
+
14
+ If the `GITHUB_TOKEN` environment variable is set, it can use that token to get more
15
+ accurate stats and avoid rate-limiting. You can also avoid needing to pass `--username`
16
+ if you have the token set.
17
+
18
+ ## Building
19
+
20
+ ### Prerequisites
21
+
22
+ This project depends on [Linguist][github-linguist], which has some dependencies that
23
+ may require extra steps. Read their documentation for more details.
24
+
25
+ #### On Ubuntu
26
+
27
+ ```shell
28
+ sudo apt install build-essential cmake pkg-config libicu-dev zlib1g-dev libcurl4-openssl-dev libssl-dev ruby-dev
29
+ ```
30
+
31
+ [dotenv-gem]: https://github.com/bkeepers/dotenv
32
+ [github-linguist]: https://github.com/github-linguist/linguist
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'dotenv'
5
+ require 'github_polyglot'
6
+ require 'github_polyglot/cli'
7
+
8
+ Dotenv.load
9
+
10
+ cli = GithubPolyglot::CLI.new
11
+ cli.run
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dotenv'
4
+ require 'optparse'
5
+
6
+ class GithubPolyglot
7
+ # Class for the command-line interface
8
+ class CLI
9
+ FORMAT_OPTIONS = %i[print json pretty-json svg].freeze
10
+
11
+ def initialize
12
+ parser.parse!
13
+ end
14
+
15
+ # Runs the CLI
16
+ def run
17
+ Dotenv.load
18
+
19
+ token = ENV.fetch(GithubPolyglot::TOKEN_ENV_VAR, nil)
20
+ token = nil if token&.empty?
21
+ @polyglot = GithubPolyglot.new(username: @username, token: token)
22
+ output_format
23
+ end
24
+
25
+ private
26
+
27
+ def output_format
28
+ case format
29
+ when :print then @polyglot.print
30
+ when :json then puts @polyglot.json(pretty: false)
31
+ when :'pretty-json' then puts @polyglot.json(pretty: true)
32
+ when :svg then puts @polyglot.svg
33
+ else
34
+ throw NotImplementedError, "Unexpected format: #{format}"
35
+ end
36
+ end
37
+
38
+ def parser
39
+ parser = OptionParser.new
40
+ parser.on('-u USERNAME', '-l', '--username', '--login', 'The username/login of the GitHub user') do |value|
41
+ @username = value
42
+ end
43
+ parser.on('-F FORMAT', '--format', FORMAT_OPTIONS,
44
+ "The format to output in (#{FORMAT_OPTIONS.join('/')})") do |value|
45
+ @format = value
46
+ end
47
+
48
+ parser
49
+ end
50
+
51
+ def format
52
+ @format ||= :print
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GithubPolyglot
4
+ class SVG
5
+ # Helper for language stats
6
+ module LanguageStats
7
+ # The number of the most popular languages to show. If there are more than this
8
+ # amount of languages, the least common get grouped under "Other" languages.
9
+ TOP_LANGUAGES = 6
10
+
11
+ # The number of columns in the language stats area.
12
+ STATS_COLUMNS = 2
13
+
14
+ # The height of a language stat row.
15
+ STAT_ROW_HEIGHT = 20
16
+
17
+ # Classes for the language name in language stats.
18
+ LANGUAGE_NAME_CLASS = 'text-bold'
19
+
20
+ # Class for the language percentage.
21
+ LANGUAGE_PERCENTAGE_CLASS = 'muted'
22
+
23
+ private
24
+
25
+ # Builds the language stats entries
26
+ def language_stats(xml)
27
+ summarized_languages.each_with_index do |(language, amount), index|
28
+ language_stat(xml, language, amount, index)
29
+ end
30
+ end
31
+
32
+ # Adds the language stat to the SVG
33
+ def language_stat(xml, language, amount, index)
34
+ x, y = language_stats_coordinates(index)
35
+ language_stat_circle(xml, language, x, y)
36
+ x += radius * 4
37
+ y += radius
38
+ language_stat_text(xml, language, amount, x, y)
39
+ end
40
+
41
+ # Adds the circle for the language stat
42
+ def language_stat_circle(xml, language, x_coord, y_coord)
43
+ fill = color(language)
44
+ xml.circle(cx: x_coord + radius, cy: y_coord + radius, r: radius, fill: fill)
45
+ end
46
+
47
+ # Adds the text for the language stat
48
+ def language_stat_text(xml, language, amount, x_coord, y_coord)
49
+ percentage = (amount * 100.0 / total_amount).round(1)
50
+ xml.text_(x: x_coord, y: y_coord, 'dominant-baseline': 'middle') do
51
+ xml.tspan(class: LANGUAGE_NAME_CLASS) do
52
+ xml.text language.to_s
53
+ end
54
+ xml.tspan(class: LANGUAGE_PERCENTAGE_CLASS) { xml.text " #{percentage}%" }
55
+ end
56
+ end
57
+
58
+ # Gets coordinates for where the language stats should be.
59
+ def language_stats_coordinates(index)
60
+ y_offset = BAR_HEIGHT + BAR_PADDING
61
+ row_num = index / STATS_COLUMNS
62
+ col_num = index % STATS_COLUMNS
63
+ x = (width.to_f / STATS_COLUMNS) * col_num
64
+ y = y_offset + (row_num * STAT_ROW_HEIGHT)
65
+ [x, y]
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,25 @@
1
+ :root {
2
+ font-family: "Noto Sans", Helvetica, Arial, system-ui, sans-serif;
3
+ font-size: 12px;
4
+ font-weight: 400;
5
+ --text-color: #1F2328;
6
+ --text-color-muted: #59636E;
7
+ }
8
+
9
+ @media (prefers-color-scheme: dark) {
10
+ :root {
11
+ --text-color: #D1D7E0;
12
+ --text-color-muted: #9198A1;
13
+ }
14
+ }
15
+
16
+ text, tspan {
17
+ fill: var(--text-color);
18
+ }
19
+ tspan.muted {
20
+ fill: var(--text-color-muted);
21
+ }
22
+
23
+ .text-bold {
24
+ font-weight: 600;
25
+ }
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'linguist'
4
+ require 'nokogiri'
5
+ require 'github_polyglot/svg/language_stats'
6
+
7
+ class GithubPolyglot
8
+ # Generates SVG for language stats
9
+ class SVG
10
+ include LanguageStats
11
+
12
+ # Width of the SVG image
13
+ WIDTH = 300
14
+
15
+ # Height of the language bar
16
+ BAR_HEIGHT = 8
17
+
18
+ # Padding underneath the language bar
19
+ BAR_PADDING = 8
20
+
21
+ # Radii of rounded elements
22
+ RADII = 5
23
+
24
+ # The name of the entry for remaining languages.
25
+ OTHER_LANGUAGES = :Other
26
+
27
+ # The default color to use if a language doesn't have a color
28
+ DEFAULT_COLOR = '#EDEDED'
29
+
30
+ # The CSS text that gets applied to the SVG.
31
+ CSS = File.read(File.expand_path('svg.css', __dir__))
32
+
33
+ # @param [Hash] languages Maps language name symbols to amounts.
34
+ def initialize(languages)
35
+ @languages = languages
36
+ end
37
+
38
+ # Creates an SVG string
39
+ def generate
40
+ mask_id = 'mask'
41
+ builder = Nokogiri::XML::Builder.new do |xml|
42
+ xml.svg(xmlns: 'http://www.w3.org/2000/svg', width: width, height: height, viewBox: view_box) do
43
+ xml.style(type: 'text/css') { xml.cdata(CSS) }
44
+ body(xml, mask_id)
45
+ end
46
+ end
47
+
48
+ builder.to_xml
49
+ end
50
+
51
+ # Gets the width of the SVG
52
+ def width
53
+ WIDTH
54
+ end
55
+
56
+ # Gets the height of the SVG
57
+ def height
58
+ BAR_HEIGHT + BAR_PADDING + stats_height
59
+ end
60
+
61
+ # Gets the radii of rounded elements
62
+ def radii
63
+ RADII
64
+ end
65
+
66
+ # Alias for radii
67
+ def radius
68
+ RADII
69
+ end
70
+
71
+ private
72
+
73
+ # The view box for the SVG
74
+ def view_box
75
+ [0, 0, width, height].map(&:to_s).join(' ')
76
+ end
77
+
78
+ # Builds the xml body
79
+ def body(xml, mask_id)
80
+ xml.defs do
81
+ mask(xml, mask_id)
82
+ end
83
+ language_group(xml, mask_id)
84
+ language_stats(xml)
85
+ end
86
+
87
+ # Builds the mask for the rounded corners.
88
+ def mask(xml, id)
89
+ xml.mask('mask-type': 'luminance', id: id) do
90
+ xml.rect(x: 0, y: 0, width: width, height: BAR_HEIGHT, rx: radius, ry: radius, fill: 'white')
91
+ end
92
+ end
93
+
94
+ # Builds the language bar
95
+ def language_group(xml, mask_id)
96
+ xml.g(mask: "url(##{mask_id})") do
97
+ language_x = 0.0
98
+ summarized_languages.each do |language, amount|
99
+ language_x = language_entry(xml, language_x, language, amount)
100
+ end
101
+ end
102
+ end
103
+
104
+ # Builds a single language entry for the language bar
105
+ # @return [Integer, Float] The new offset on the X-axis for the next language entry
106
+ def language_entry(xml, x_offset, language, amount)
107
+ ratio = amount / total_amount
108
+ language_width = width * ratio
109
+ fill = color(language)
110
+
111
+ xml.rect(x: x_offset, y: 0, width: language_width, height: BAR_HEIGHT, fill: fill)
112
+
113
+ x_offset + language_width
114
+ end
115
+
116
+ # Returns the languages sorted by amount.
117
+ def sorted_languages
118
+ @sorted_languages ||= @languages.sort_by { |_, amount| -amount }
119
+ end
120
+
121
+ # Returns the language entries, but groups the least popular under `:Other`.
122
+ def summarized_languages
123
+ return @summarized_languages if @summarized_languages
124
+
125
+ languages = sorted_languages
126
+ return @summarized_languages = languages if languages.length <= TOP_LANGUAGES
127
+
128
+ @summarized_languages = languages.slice(0, TOP_LANGUAGES)
129
+ @summarized_languages << [OTHER_LANGUAGES, languages[TOP_LANGUAGES..].inject(0) do |total, (_, amount)|
130
+ total + amount
131
+ end]
132
+ end
133
+
134
+ # Gets the total amount for languages.
135
+ def total_amount
136
+ @total_amount ||= @languages.values.sum.to_f
137
+ end
138
+
139
+ # Gets the color as a hex code string or CSS color for a language.
140
+ #
141
+ # @param [String, Symbol] language The name of the language.
142
+ def color(language)
143
+ return DEFAULT_COLOR if language == OTHER_LANGUAGES
144
+
145
+ language = language.to_s
146
+
147
+ Linguist::Language[language]&.color || DEFAULT_COLOR
148
+ end
149
+
150
+ # Gets the height of the language stats entries.
151
+ def stats_height
152
+ stats_rows * STAT_ROW_HEIGHT
153
+ end
154
+
155
+ # Gets the number of rows for language stats entries.
156
+ def stats_rows
157
+ # NOTE: + 1 because of the potential extra "Other" entry
158
+ ((TOP_LANGUAGES + 1) / STATS_COLUMNS.to_f).ceil
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'github_polyglot/svg'
4
+ require 'json'
5
+ require 'octokit'
6
+
7
+ # Gets language usage stats for a GitHub user.
8
+ class GithubPolyglot
9
+ TOKEN_ENV_VAR = 'GITHUB_TOKEN'
10
+
11
+ # @param [String, Nil] username The username to look up.
12
+ # @param [String, Nil] token The GitHub token to use in requests.
13
+ def initialize(username: nil, token: nil)
14
+ @username = username
15
+ @token = token
16
+ @client = Octokit::Client.new(access_token: token)
17
+ end
18
+
19
+ # Gets the username to use.
20
+ def username
21
+ return @username unless @username.nil?
22
+
23
+ @username = token_username
24
+ @username
25
+ end
26
+
27
+ # Gets language stats for the user.
28
+ def languages
29
+ compiled = Hash.new(0)
30
+ each_repo do |repo|
31
+ next if repo[:fork]
32
+
33
+ languages = repo_languages(repo.name)
34
+ languages.to_h.each_pair do |language, size|
35
+ compiled[language] += size
36
+ end
37
+ end
38
+ compiled
39
+ end
40
+
41
+ # Gets the language stats for the user as JSON.
42
+ def json(pretty: false)
43
+ options = pretty ? { indent: ' ', space: ' ' } : { indent: '', space: '', array_nl: '', object_nl: '' }
44
+ JSON.pretty_generate(languages, options)
45
+ end
46
+
47
+ # Prints the languages
48
+ def print
49
+ languages.each_pair do |language, amount|
50
+ puts "#{language}: #{amount}"
51
+ end
52
+ end
53
+
54
+ # Generates an SVG string for the languages
55
+ def svg
56
+ generator = SVG.new(languages)
57
+ generator.generate
58
+ end
59
+
60
+ private
61
+
62
+ # Gets the language stats for a single repository from the GitHub API.
63
+ #
64
+ # @param [String] repository The name of the owner's repository.
65
+ def repo_languages(repository_name)
66
+ @client.languages(repo_qualifier(repository_name))
67
+ end
68
+
69
+ # Returns the repository path (`owner/repository`) using the username as the owner.
70
+ def repo_qualifier(repository_name)
71
+ "#{username}/#{repository_name}"
72
+ end
73
+
74
+ # Yields each repository
75
+ def each_repo(&block)
76
+ page = 1
77
+ loop do
78
+ repos = @client.repos(username, query: { page: page })
79
+ break unless repos && !repos.empty?
80
+
81
+ repos.each(&block)
82
+ page += 1
83
+ end
84
+ end
85
+
86
+ # Gets the username for the authenticated token.
87
+ # @return [String, Nil] The authenticated user.
88
+ def token_username
89
+ return nil if @token.nil?
90
+
91
+ @client.user.login
92
+ end
93
+ end
metadata ADDED
@@ -0,0 +1,146 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: github-polyglot
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Spenser Black
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-06-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dotenv
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-retry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.4'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.4'
41
+ - !ruby/object:Gem::Dependency
42
+ name: github-linguist
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '9.3'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '9.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: json
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.18'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.18'
69
+ - !ruby/object:Gem::Dependency
70
+ name: nokogiri
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.18'
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 1.18.10
79
+ type: :runtime
80
+ prerelease: false
81
+ version_requirements: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - "~>"
84
+ - !ruby/object:Gem::Version
85
+ version: '1.18'
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 1.18.10
89
+ - !ruby/object:Gem::Dependency
90
+ name: octokit
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '10.0'
96
+ type: :runtime
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '10.0'
103
+ description: "(Unofficial) Fetches and aggregates language usage statistics for a
104
+ GitHub user"
105
+ email:
106
+ executables:
107
+ - github-polyglot
108
+ extensions: []
109
+ extra_rdoc_files: []
110
+ files:
111
+ - LICENSE
112
+ - README.md
113
+ - exe/github-polyglot
114
+ - lib/github_polyglot.rb
115
+ - lib/github_polyglot/cli.rb
116
+ - lib/github_polyglot/svg.css
117
+ - lib/github_polyglot/svg.rb
118
+ - lib/github_polyglot/svg/language_stats.rb
119
+ homepage: https://github.com/spenserblack/github-polyglot
120
+ licenses:
121
+ - MIT
122
+ metadata:
123
+ homepage_uri: https://github.com/spenserblack/github-polyglot
124
+ source_code_uri: https://github.com/spenserblack/github-polyglot
125
+ github_repo: https://github.com/spenserblack/github-polyglot
126
+ rubygems_mfa_required: 'true'
127
+ post_install_message:
128
+ rdoc_options: []
129
+ require_paths:
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '3.2'
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
141
+ requirements: []
142
+ rubygems_version: 3.5.22
143
+ signing_key:
144
+ specification_version: 4
145
+ summary: "(Unofficial) Language stats for the GitHub user"
146
+ test_files: []