csspin-rails 1.0.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: c1717d5c0156c805e78da55e7b3646c305586cc9d74b243d9799b29d76dd2e75
4
+ data.tar.gz: 6fa180b07893e1c5a119da73c80318eacd7663791ae2c6c149c46031fe8cde45
5
+ SHA512:
6
+ metadata.gz: 827c66a027c92c20c3e236596903b70cf462eadd8b081dcd491cdb60129791e88b004dfe98576ab8b192a17a90787e766397d3d8e72921f2133e90e67dad4229
7
+ data.tar.gz: 7a04c7c035d9fa58e151c0c4af7456b9ce0c7923c78495de426dbef7bb99e63ac5310481ac071a0c681d998d77c45a6313c9e4f7e3aebc3867a11600b033c219
data/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # csspin-rails
2
+
3
+ [![CI](https://github.com/elalemanyo/csspin-rails/actions/workflows/ci.yml/badge.svg)](https://github.com/elalemanyo/csspin-rails/actions/workflows/ci.yml)
4
+
5
+ Pin CSS packages from npm into a Rails app's `vendor/assets/stylesheets` — without Node.js.
6
+
7
+ Works like [importmap-rails](https://github.com/rails/importmap-rails), but for CSS. Packages are fetched from [jsDelivr](https://www.jsdelivr.com/).
8
+
9
+ ## Installation
10
+
11
+ Add to your Gemfile:
12
+
13
+ ```ruby
14
+ gem "csspin-rails"
15
+ ```
16
+
17
+ Then run:
18
+
19
+ ```bash
20
+ bundle install
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ # Pin a package (latest version)
27
+ bundle exec csspin pin trix
28
+
29
+ # Pin a specific version
30
+ bundle exec csspin pin trix@2.0.0
31
+
32
+ # Pin a scoped package
33
+ bundle exec csspin pin @scope/package@1.2.3
34
+ ```
35
+
36
+ CSS is saved to `vendor/assets/stylesheets/<package>.css`.
37
+
38
+ If you want `bin/csspin`, run `bundle binstubs csspin-rails` first.
39
+
40
+ After pinning, the CLI prints integration snippets:
41
+
42
+ ```
43
+ Sprockets snippet: *= require trix
44
+ Sass snippet: @import "trix";
45
+ ```
46
+
47
+ ## CLI
48
+
49
+ ```
50
+ Usage: csspin pin <package|package@version>
51
+
52
+ Commands:
53
+ pin <package|package@version> Download a CSS package into vendor/assets/stylesheets
54
+
55
+ Options:
56
+ -v, --version Show version
57
+ -h, --help Show this help
58
+ ```
59
+
60
+ ## How it works
61
+
62
+ 1. Parses the package name and optional version
63
+ 2. Resolves candidate URLs on jsDelivr (`/dist/<pkg>.css`, then the bare package URL)
64
+ 3. Downloads the first successful CSS response (follows redirects, validates content-type)
65
+ 4. Writes the file to `vendor/assets/stylesheets/`
66
+
67
+ ## Development
68
+
69
+ ```bash
70
+ bundle install
71
+ bundle exec standardrb
72
+ bundle exec rake test
73
+ ```
74
+
75
+ ## License
76
+
77
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/exe/csspin ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
3
+ require "csspin"
4
+
5
+ exit Csspin::CLI.run(argv: ARGV)
data/lib/csspin/cli.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csspin
4
+ class CLI
5
+ USAGE = "Usage: csspin pin <package|package@version>"
6
+
7
+ def self.run(argv:, root: Dir.pwd, io: $stdout, error_io: $stderr, pin_command: nil)
8
+ command = argv[0]
9
+
10
+ if command == "--version" || command == "-v"
11
+ io.puts "csspin-rails #{Csspin::VERSION}"
12
+ return 0
13
+ end
14
+
15
+ if command == "--help" || command == "-h" || command.nil?
16
+ io.puts USAGE
17
+ io.puts
18
+ io.puts "Commands:"
19
+ io.puts " pin <package|package@version> Download a CSS package into vendor/assets/stylesheets"
20
+ io.puts
21
+ io.puts "Options:"
22
+ io.puts " -v, --version Show version"
23
+ io.puts " -h, --help Show this help"
24
+ return 0
25
+ end
26
+
27
+ unless command == "pin"
28
+ error_io.puts "Unknown command: #{command}"
29
+ error_io.puts USAGE
30
+ return 1
31
+ end
32
+
33
+ input = argv[1]
34
+ if input.to_s.strip.empty?
35
+ error_io.puts USAGE
36
+ return 1
37
+ end
38
+
39
+ downloader = Csspin::HTTP::Downloader.new
40
+
41
+ cmd = pin_command || Csspin::Commands::Pin.new(
42
+ resolver: Csspin::Resolver::Jsdelivr.new,
43
+ downloader: downloader,
44
+ fallback_candidates: Csspin::CssFallbackCandidates.new(
45
+ metadata_client: Csspin::JsdelivrMetadataClient.new(downloader: downloader)
46
+ ),
47
+ writer: Csspin::VendorWriter.new(root: root),
48
+ printer: Csspin::InstructionsPrinter.new(io: io),
49
+ error_io: error_io
50
+ )
51
+
52
+ cmd.run(input: input, root: root, io: io)
53
+ rescue ArgumentError => e
54
+ error_io.puts e.message
55
+ error_io.puts USAGE
56
+ 1
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csspin
4
+ module Commands
5
+ class Pin
6
+ def initialize(resolver:, downloader:, writer:, printer:, fallback_candidates:, error_io: $stderr)
7
+ @resolver = resolver
8
+ @downloader = downloader
9
+ @writer = writer
10
+ @printer = printer
11
+ @error_io = error_io
12
+ @fallback_candidates = fallback_candidates
13
+ end
14
+
15
+ def run(input:, root:, io:)
16
+ spec = Csspin::PackageSpec.parse(input)
17
+
18
+ urls = @resolver.url_candidates_for(spec)
19
+ tried_urls = []
20
+ result = download_first_success(urls, tried_urls)
21
+
22
+ unless result
23
+ fallback_urls = @fallback_candidates.for(spec).reject { |url| tried_urls.include?(url) }
24
+ result = download_first_success(fallback_urls, tried_urls)
25
+ end
26
+
27
+ unless result
28
+ @error_io.puts "Unable to download CSS from jsDelivr for #{spec.full_name}"
29
+ @error_io.puts "Tried: #{tried_urls.join(", ")}"
30
+ return 1
31
+ end
32
+
33
+ saved_path = @writer.write(package_name: spec.package_name, content: result[:body])
34
+
35
+ snippet_package = File.basename(saved_path, ".css")
36
+
37
+ @printer.print_success(
38
+ package_name: snippet_package,
39
+ saved_path: saved_path,
40
+ source_url: result[:url]
41
+ )
42
+
43
+ 0
44
+ rescue ArgumentError => e
45
+ @error_io.puts e.message
46
+ 1
47
+ rescue => e
48
+ @error_io.puts "Pin failed: #{e.message}"
49
+ 1
50
+ end
51
+
52
+ private
53
+
54
+ def download_first_success(urls, tried_urls)
55
+ urls.each do |url|
56
+ tried_urls << url
57
+ response = @downloader.get(url)
58
+ return response.merge(url: url) if response[:ok]
59
+ end
60
+ nil
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csspin
4
+ class CssFallbackCandidates
5
+ def initialize(metadata_client:)
6
+ @metadata_client = metadata_client
7
+ end
8
+
9
+ def for(package_spec)
10
+ metadata = @metadata_client.fetch(package_spec.full_name)
11
+
12
+ [
13
+ metadata_path_for(metadata, "style"),
14
+ metadata_path_for(metadata, "default"),
15
+ *css_paths_for(metadata),
16
+ conventional_path_for(package_spec, "dist/css/"),
17
+ conventional_path_for(package_spec)
18
+ ].compact.map { |path| normalize_path(path, package_spec.full_name) }
19
+ .uniq
20
+ .map { |path| candidate_url_for(path, package_spec.full_name) }
21
+ end
22
+
23
+ private
24
+
25
+ def metadata_path_for(metadata, key)
26
+ path = metadata[key]
27
+ path if path&.end_with?(".css")
28
+ end
29
+
30
+ def conventional_path_for(package_spec, prefix = "")
31
+ "#{prefix}#{package_spec.package_name}.min.css"
32
+ end
33
+
34
+ def css_paths_for(metadata)
35
+ css_paths_from_entries(metadata["files"])
36
+ end
37
+
38
+ def css_paths_from_entries(entries, prefix = nil)
39
+ Array(entries).each_with_object([]) do |entry, paths|
40
+ next unless entry.is_a?(Hash)
41
+
42
+ name = entry["name"].to_s
43
+
44
+ case entry["type"]
45
+ when "directory"
46
+ path = [prefix, name].compact.join("/")
47
+ paths.concat(css_paths_from_entries(entry["files"], path))
48
+ when "file"
49
+ next unless name.end_with?(".css")
50
+
51
+ paths << [prefix, name].compact.join("/")
52
+ end
53
+ end
54
+ end
55
+
56
+ def normalize_path(path, full_name)
57
+ path.sub("https://cdn.jsdelivr.net/npm/#{full_name}/", "")
58
+ end
59
+
60
+ def candidate_url_for(path, full_name)
61
+ return path if path.start_with?("https://cdn.jsdelivr.net/npm/")
62
+
63
+ "https://cdn.jsdelivr.net/npm/#{full_name}/#{path}"
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Csspin
7
+ module HTTP
8
+ class Downloader
9
+ MAX_REDIRECTS = 5
10
+ DEFAULT_ALLOWED_CONTENT_TYPES = ["text/css", "text/plain"].freeze
11
+
12
+ def get(url, allowed_content_types: DEFAULT_ALLOWED_CONTENT_TYPES)
13
+ uri = URI.parse(url)
14
+ redirects = 0
15
+
16
+ loop do
17
+ http = build_http(uri)
18
+ response = http.start { |h| h.get(uri.request_uri) }
19
+
20
+ if response.is_a?(Net::HTTPRedirection)
21
+ redirects += 1
22
+ return {ok: false, error: "Too many redirects", status: response.code.to_i} if redirects > MAX_REDIRECTS
23
+
24
+ location = response["location"]
25
+ uri = URI.join(uri, location)
26
+ next
27
+ end
28
+
29
+ if response.is_a?(Net::HTTPSuccess)
30
+ content_type = response["content-type"].to_s
31
+ unless allowed_content_types.any? { |allowed_content_type| content_type.include?(allowed_content_type) }
32
+ return {ok: false, error: "Not a CSS file (content-type: #{content_type})", status: response.code.to_i}
33
+ end
34
+
35
+ return {ok: true, body: response.body}
36
+ end
37
+
38
+ return {ok: false, error: "HTTP #{response.code}", status: response.code.to_i}
39
+ end
40
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, SocketError, URI::InvalidURIError => e
41
+ {ok: false, error: e.message, status: nil}
42
+ end
43
+
44
+ private
45
+
46
+ def build_http(uri)
47
+ http = Net::HTTP.new(uri.host, uri.port)
48
+ http.use_ssl = uri.scheme == "https"
49
+ http.open_timeout = 5
50
+ http.read_timeout = 10
51
+ http.write_timeout = 5
52
+ http
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csspin
4
+ class InstructionsPrinter
5
+ def initialize(io: $stdout)
6
+ @io = io
7
+ end
8
+
9
+ def print_success(package_name:, saved_path:, source_url:)
10
+ @io.puts "Saved CSS to: #{saved_path}"
11
+ @io.puts "Source URL: #{source_url}"
12
+ @io.puts
13
+ @io.puts "Sprockets snippet: *= require #{package_name}"
14
+ @io.puts "Sass snippet: @import \"#{package_name}\";"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Csspin
6
+ class JsdelivrMetadataClient
7
+ def initialize(downloader:)
8
+ @downloader = downloader
9
+ end
10
+
11
+ def fetch(full_name)
12
+ metadata = fetch_json(full_name)
13
+ return {} unless metadata
14
+
15
+ if !metadata.key?("files") && (latest = resolve_latest_version(metadata))
16
+ versioned = fetch_json("#{full_name}@#{latest}")
17
+ return versioned if versioned
18
+ end
19
+
20
+ metadata
21
+ end
22
+
23
+ private
24
+
25
+ def fetch_json(full_name)
26
+ response = @downloader.get(
27
+ "https://data.jsdelivr.com/v1/packages/npm/#{full_name}",
28
+ allowed_content_types: ["application/json", "text/json"]
29
+ )
30
+ return nil unless response[:ok]
31
+
32
+ metadata = JSON.parse(response[:body])
33
+ metadata.is_a?(Hash) ? metadata : nil
34
+ rescue JSON::ParserError, TypeError
35
+ nil
36
+ end
37
+
38
+ def resolve_latest_version(metadata)
39
+ metadata.dig("tags", "latest")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csspin
4
+ class PackageSpec
5
+ attr_reader :full_name, :package_name
6
+
7
+ def self.parse(raw)
8
+ normalized = raw.to_s.strip
9
+ raise ArgumentError, "package is required" if normalized.empty?
10
+
11
+ package_part, = split_package_and_version(normalized)
12
+
13
+ package_name = package_part.split("/").last
14
+ new(full_name: normalized, package_name: package_name)
15
+ end
16
+
17
+ def self.split_package_and_version(normalized)
18
+ return validate_unscoped_package!(normalized) unless normalized.include?("@")
19
+
20
+ if normalized.start_with?("@")
21
+ scope, remainder = normalized.split("/", 2)
22
+ raise ArgumentError, "invalid package format: #{normalized}" if scope.nil? || scope.length == 1 || scope.count("@") != 1 || remainder.nil? || remainder.empty?
23
+
24
+ package, version = remainder.split("@", 2)
25
+ raise ArgumentError, "invalid package format: #{normalized}" if package.nil? || package.empty? || package.include?("/") || version&.empty? || version&.include?("/") || version&.include?("@")
26
+
27
+ ["#{scope}/#{package}", version]
28
+ else
29
+ package, version = normalized.split("@", 2)
30
+ raise ArgumentError, "invalid package format: #{normalized}" if package.nil? || package.empty? || package.include?("/") || version&.empty? || version&.include?("/") || version&.include?("@")
31
+
32
+ [package, version]
33
+ end
34
+ end
35
+
36
+ def self.validate_unscoped_package!(normalized)
37
+ raise ArgumentError, "invalid package format: #{normalized}" if normalized.empty? || normalized.include?("/")
38
+
39
+ [normalized, nil]
40
+ end
41
+
42
+ def initialize(full_name:, package_name:)
43
+ @full_name = full_name
44
+ @package_name = package_name
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csspin
4
+ module Resolver
5
+ class Jsdelivr
6
+ def url_candidates_for(package_spec)
7
+ full = package_spec.full_name
8
+ package = package_spec.package_name
9
+
10
+ [
11
+ "https://cdn.jsdelivr.net/npm/#{full}/dist/#{package}.css",
12
+ "https://cdn.jsdelivr.net/npm/#{full}"
13
+ ]
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Csspin
6
+ class VendorWriter
7
+ def initialize(root: Dir.pwd)
8
+ @root = root
9
+ end
10
+
11
+ def write(package_name:, content:)
12
+ package_name = package_name.tr("/", "-").gsub(/[^a-zA-Z0-9\-_]/, "")
13
+
14
+ dir = File.join(@root, "vendor/assets/stylesheets")
15
+ FileUtils.mkdir_p(dir)
16
+
17
+ path = File.join(dir, "#{package_name}.css")
18
+ File.write(path, content)
19
+ path
20
+ rescue Errno::ENOENT, Errno::EACCES => e
21
+ raise "Failed to write vendor file: #{e.message}"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csspin
4
+ VERSION = "1.0.0"
5
+ end
data/lib/csspin.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "csspin/version"
4
+ require_relative "csspin/package_spec"
5
+ require_relative "csspin/css_fallback_candidates"
6
+ require_relative "csspin/jsdelivr_metadata_client"
7
+ require_relative "csspin/resolver/jsdelivr"
8
+ require_relative "csspin/http/downloader"
9
+ require_relative "csspin/vendor_writer"
10
+ require_relative "csspin/instructions_printer"
11
+ require_relative "csspin/commands/pin"
12
+ require_relative "csspin/cli"
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: csspin-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - elalemanyo
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-04-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: standard
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: A CLI tool to pin CSS packages from npm (via jsDelivr) into a Rails app's
56
+ vendor/assets/stylesheets without Node.js.
57
+ email:
58
+ executables:
59
+ - csspin
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - README.md
64
+ - exe/csspin
65
+ - lib/csspin.rb
66
+ - lib/csspin/cli.rb
67
+ - lib/csspin/commands/pin.rb
68
+ - lib/csspin/css_fallback_candidates.rb
69
+ - lib/csspin/http/downloader.rb
70
+ - lib/csspin/instructions_printer.rb
71
+ - lib/csspin/jsdelivr_metadata_client.rb
72
+ - lib/csspin/package_spec.rb
73
+ - lib/csspin/resolver/jsdelivr.rb
74
+ - lib/csspin/vendor_writer.rb
75
+ - lib/csspin/version.rb
76
+ homepage: https://github.com/elalemanyo/csspin-rails
77
+ licenses:
78
+ - MIT
79
+ metadata:
80
+ homepage_uri: https://github.com/elalemanyo/csspin-rails
81
+ source_code_uri: https://github.com/elalemanyo/csspin-rails
82
+ rubygems_mfa_required: 'true'
83
+ post_install_message:
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '3.1'
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 3.5.22
99
+ signing_key:
100
+ specification_version: 4
101
+ summary: Pin CSS dependencies into Rails vendor assets
102
+ test_files: []