Class: StyleCapsule::CssFileWriter

Inherits:
Object
  • Object
show all
Defined in:
lib/style_capsule/css_file_writer.rb,
sig/style_capsule.rbs

Overview

Writes inline CSS to files for HTTP caching

This allows inline CSS to be cached by browsers and CDNs, improving performance. Files are written to a configurable output directory and can be precompiled via Rails asset pipeline.

In production environments where the app directory is read-only (e.g., Docker containers), this class automatically falls back to writing files to /tmp/style_capsule when the default location is not writable. When using the fallback directory, write_css returns nil, causing StylesheetRegistry to fall back to inline CSS (keeping the UI functional).

All fallback scenarios are instrumented via ActiveSupport::Notifications following Rails conventions (https://guides.rubyonrails.org/active_support_instrumentation.html):

  • style_capsule.css_file_writer.fallback: When fallback directory is used successfully
  • style_capsule.css_file_writer.fallback_failure: When both primary and fallback fail
  • style_capsule.css_file_writer.write_failure: When other write errors occur

All events include exception information in the standard Rails format:

  • :exception: Array of [class_name, message]
  • :exception_object: The exception object itself

These events can be subscribed to for monitoring, metrics collection, and error reporting.

Examples:

Configuration

StyleCapsule::CssFileWriter.configure(
  output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
  filename_pattern: ->(component_class, capsule_id) { "capsule-#{capsule_id}.css" },
  fallback_dir: "/tmp/style_capsule"  # Optional, defaults to /tmp/style_capsule
)

Usage

file_path = StyleCapsule::CssFileWriter.write_css(
  css_content: ".section { color: red; }",
  component_class: MyComponent,
  capsule_id: "abc123"
)
# => "capsules/capsule-abc123" (or nil if fallback was used)

Listening to instrumentation events for monitoring

ActiveSupport::Notifications.subscribe("style_capsule.css_file_writer.fallback") do |name, start, finish, id, payload|
  Rails.logger.warn "StyleCapsule fallback: #{payload[:component_class]} -> #{payload[:fallback_path]}"
  # Exception info available: payload[:exception] and payload[:exception_object]
end

Subscribing for error reporting

ActiveSupport::Notifications.subscribe("style_capsule.css_file_writer.fallback_failure") do |name, start, finish, id, payload|
  ActionReporter.notify(
    "StyleCapsule: CSS write failure (both primary and fallback failed)",
    context: {
      component_class: payload[:component_class],
      original_path: payload[:original_path],
      fallback_path: payload[:fallback_path],
      original_exception: payload[:original_exception],
      fallback_exception: payload[:fallback_exception]
    }
  )
end

Subscribing for metrics collection

ActiveSupport::Notifications.subscribe("style_capsule.css_file_writer.fallback") do |name, start, finish, id, payload|
  StatsD.increment("style_capsule.css_file_writer.fallback", tags: [
    "component:#{payload[:component_class]}",
    "error:#{payload[:exception].first}"
  ])
end

Constant Summary collapse

DEFAULT_OUTPUT_DIR =

Default output directory for CSS files (relative to Rails root)

"app/assets/builds/capsules"
FALLBACK_OUTPUT_DIR =

Fallback directory for when default location is read-only (absolute path)

"/tmp/style_capsule"
FILE_PATH_CACHE_MAX =

Positive cache for resolved asset-relative paths (avoids repeated File.exist? on hot path)

512

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.enabledObject

Returns the value of attribute enabled.



82
83
84
# File 'lib/style_capsule/css_file_writer.rb', line 82

def enabled
  @enabled
end

.fallback_dirObject

Returns the value of attribute fallback_dir.



82
83
84
# File 'lib/style_capsule/css_file_writer.rb', line 82

def fallback_dir
  @fallback_dir
end

.filename_patternObject

Returns the value of attribute filename_pattern.



82
83
84
# File 'lib/style_capsule/css_file_writer.rb', line 82

def filename_pattern
  @filename_pattern
end

.output_dirObject

Returns the value of attribute output_dir.



82
83
84
# File 'lib/style_capsule/css_file_writer.rb', line 82

def output_dir
  @output_dir
end

Class Method Details

.clear_filesvoid

This method returns an undefined value.

Clear all generated CSS files



267
268
269
270
271
272
273
274
275
# File 'lib/style_capsule/css_file_writer.rb', line 267

def clear_files
  return unless enabled?

  clear_file_path_hit_cache!
  dir = output_directory
  return unless Dir.exist?(dir)

  Dir.glob(dir.join("*.css")).each { |file| File.delete(file) }
end

.configure(output_dir: nil, filename_pattern: nil, enabled: true, fallback_dir: nil) ⇒ void

This method returns an undefined value.

Configure CSS file writer

Examples:

StyleCapsule::CssFileWriter.configure(
  output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
  filename_pattern: ->(klass, capsule) { "capsule-#{capsule}.css" }
)

Custom pattern with component name

StyleCapsule::CssFileWriter.configure(
  filename_pattern: ->(klass, capsule) { "#{klass.name.underscore}-#{capsule}.css" }
)

Parameters:

  • output_dir (String, Pathname) (defaults to: nil)

    Directory to write CSS files (relative to Rails root or absolute) Default: StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR

  • filename_pattern (Proc, nil) (defaults to: nil)

    Proc to generate filename Receives: (component_class, capsule_id) and should return filename string Default: "capsule-#{capsule_id}.css" (capsule_id is unique and deterministic)

  • enabled (Boolean) (defaults to: true)

    Whether file writing is enabled (default: true)

  • fallback_dir (String, Pathname, nil) (defaults to: nil)

    Fallback directory when default location is read-only Default: StyleCapsule::CssFileWriter::FALLBACK_OUTPUT_DIR (/tmp/style_capsule)

  • output_dir: (String, Pathname) (defaults to: nil)
  • filename_pattern: (Proc) (defaults to: nil)
  • enabled: (Boolean) (defaults to: true)


103
104
105
106
107
108
109
# File 'lib/style_capsule/css_file_writer.rb', line 103

def configure(output_dir: nil, filename_pattern: nil, enabled: true, fallback_dir: nil)
  clear_file_path_hit_cache!
  @enabled = enabled
  @output_dir = resolve_output_dir(output_dir)
  @fallback_dir = resolve_path(fallback_dir, FALLBACK_OUTPUT_DIR)
  @filename_pattern = filename_pattern || default_filename_pattern
end

.enabled?Boolean

Check if file writing is enabled

Returns:

  • (Boolean)


280
281
282
# File 'lib/style_capsule/css_file_writer.rb', line 280

def enabled?
  @enabled != false
end

.ensure_fallback_directoryvoid

This method returns an undefined value.

Ensure fallback directory exists



257
258
259
260
261
262
# File 'lib/style_capsule/css_file_writer.rb', line 257

def ensure_fallback_directory
  return unless enabled?

  dir = fallback_directory
  FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
end

.ensure_output_directoryvoid

This method returns an undefined value.

Ensure output directory exists



247
248
249
250
251
252
# File 'lib/style_capsule/css_file_writer.rb', line 247

def ensure_output_directory
  return unless enabled?

  dir = output_directory
  FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
end

.file_exists?(component_class:, capsule_id:) ⇒ Boolean

Check if file exists for given component and capsule

Parameters:

  • component_class (Class)

    Component class

  • capsule_id (String)

    Capsule ID

  • component_class: (Class)
  • capsule_id: (String)

Returns:

  • (Boolean)


212
213
214
215
216
217
218
# File 'lib/style_capsule/css_file_writer.rb', line 212

def file_exists?(component_class:, capsule_id:)
  return false unless enabled?

  filename = generate_filename(component_class, capsule_id)
  file_path = output_directory.join(filename)
  File.exist?(file_path)
end

.file_path_for(component_class:, capsule_id:) ⇒ String?

Get file path for given component and capsule

Parameters:

  • component_class (Class)

    Component class

  • capsule_id (String)

    Capsule ID

  • component_class: (Class)
  • capsule_id: (String)

Returns:

  • (String, nil)

    Relative file path or nil if disabled



225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/style_capsule/css_file_writer.rb', line 225

def file_path_for(component_class:, capsule_id:)
  return nil unless enabled?

  cache_key = "#{component_class.name}:#{capsule_id}"
  hit = file_path_hit_cache_mutex.synchronize { file_path_hit_cache[cache_key] }
  return hit if hit

  filename = generate_filename(component_class, capsule_id)
  file_path = output_directory.join(filename)

  return nil unless File.exist?(file_path)

  # Return relative path for stylesheet_link_tag
  # Handle case where output directory is not under rails_assets_root (e.g., in tests)
  rel = asset_logical_path(file_path, filename)
  remember_file_path_hit(cache_key, rel)
  rel
end

.rails_available?Boolean

Check if Rails is available This method can be stubbed in tests to test fallback paths

Returns:

  • (Boolean)


286
287
288
# File 'lib/style_capsule/css_file_writer.rb', line 286

def rails_available?
  defined?(Rails) && Rails.respond_to?(:root) && Rails.root
end

.resolve_output_dir(output_dir) ⇒ Object



111
112
113
114
115
116
# File 'lib/style_capsule/css_file_writer.rb', line 111

def resolve_output_dir(output_dir)
  return resolve_path(output_dir, DEFAULT_OUTPUT_DIR) if output_dir
  return Rails.root.join(DEFAULT_OUTPUT_DIR) if rails_available?

  Pathname.new(DEFAULT_OUTPUT_DIR)
end

.resolve_path(dir, default_path) ⇒ Object



118
119
120
121
122
# File 'lib/style_capsule/css_file_writer.rb', line 118

def resolve_path(dir, default_path)
  return Pathname.new(default_path) unless dir

  dir.is_a?(Pathname) ? dir : Pathname.new(dir.to_s)
end

.write_css(css_content:, component_class:, capsule_id:) ⇒ String?

Write CSS content to file

rubocop:disable Metrics/AbcSize -- writes asset file with fallback and instrumentation paths

Parameters:

  • css_content (String)

    CSS content to write

  • component_class (Class)

    Component class that generated the CSS

  • capsule_id (String)

    Capsule ID for the component

  • css_content: (String)
  • component_class: (Class)
  • capsule_id: (String)

Returns:

  • (String, nil)

    Relative file path (for stylesheet_link_tag) or nil if disabled/failed



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/style_capsule/css_file_writer.rb', line 131

def write_css(css_content:, component_class:, capsule_id:)
  return nil unless enabled?

  filename = generate_filename(component_class, capsule_id)
  file_path = output_directory.join(filename)
  used_fallback = false

  begin
    ensure_output_directory
    # Write CSS to file with explicit UTF-8 encoding
    Instrumentation.instrument_file_write(
      component_class: component_class,
      capsule_id: capsule_id,
      file_path: file_path.to_s,
      size: css_content.bytesize
    ) do
      File.write(file_path, css_content, encoding: "UTF-8")
    end
  rescue Errno::EACCES, Errno::EROFS => e
    # Permission denied or read-only filesystem - try fallback directory
    fallback_path = fallback_directory.join(filename)

    begin
      ensure_fallback_directory
      File.write(fallback_path, css_content, encoding: "UTF-8")
      used_fallback = true
      file_path = fallback_path

      # Instrument the fallback for visibility
      Instrumentation.instrument_fallback(
        component_class: component_class,
        capsule_id: capsule_id,
        original_path: output_directory.join(filename).to_s,
        fallback_path: fallback_path.to_s,
        exception: [e.class.name, e.message],
        exception_object: e
      )
    rescue => fallback_error
      # Even fallback failed - instrument and return nil (will fall back to inline CSS)
      Instrumentation.instrument_fallback_failure(
        component_class: component_class,
        capsule_id: capsule_id,
        original_path: output_directory.join(filename).to_s,
        fallback_path: fallback_path.to_s,
        original_exception: [e.class.name, e.message],
        original_exception_object: e,
        fallback_exception: [fallback_error.class.name, fallback_error.message],
        fallback_exception_object: fallback_error
      )
      return nil
    end
  rescue => e
    # Other errors - instrument and return nil (will fall back to inline CSS)
    Instrumentation.instrument_write_failure(
      component_class: component_class,
      capsule_id: capsule_id,
      file_path: file_path.to_s,
      exception: [e.class.name, e.message],
      exception_object: e
    )
    return nil
  end

  # If we used fallback directory, return nil (can't serve via asset pipeline)
  # This will cause StylesheetRegistry to fall back to inline CSS
  return nil if used_fallback

  # Return relative path for stylesheet_link_tag
  # Path should be relative to app/assets
  # Handle case where output directory is not under rails_assets_root (e.g., in tests)
  rel = asset_logical_path(file_path, filename)
  remember_file_path_hit("#{component_class.name}:#{capsule_id}", rel)
  rel
end