Codeblock
Displays a block of code with syntax highlighting and a copy button.
Setup
Syntax colors come from CodeblockTheme, Rouge's GitHub theme in light and dark. Render it once in the head of your layout: render RubyUI::CodeblockTheme.new
Usage
Example
def hello_world
puts "Hello, world!"
end
code = <<~CODE
def hello_world
puts "Hello, world!"
end
CODE
Codeblock(code, syntax: :ruby)
Line numbers
class User < ApplicationRecord has_many :posts validates :email, presence: trueendcode = <<~CODE
class User < ApplicationRecord
has_many :posts
validates :email, presence: true
end
CODE
Codeblock(code, syntax: :ruby, line_numbers: true, start_line: 10)
Diff
Mark lines with variant: :added, :removed or :highlighted.
source 'https://rubygems.org'
gem 'ruby_ui', '~> 1.5'gem 'ruby_ui', '~> 1.6'gem 'rouge'gemfile = <<~GEMFILE
source "https://rubygems.org"
gem "ruby_ui", "~> 1.6"
gem "rouge"
GEMFILE
Codeblock do
CodeblockActions do
CodeblockCopyButton(value: gemfile)
end
CodeblockContent do
CodeblockLine { "source 'https://rubygems.org'" }
CodeblockLine()
CodeblockLine(variant: :removed) { "gem 'ruby_ui', '~> 1.5'" }
CodeblockLine(variant: :added) { "gem 'ruby_ui', '~> 1.6'" }
CodeblockLine(variant: :highlighted) { "gem 'rouge'" }
end
end
Wrap
Long lines wrap instead of scrolling sideways.
curl -fsSL https://example.com/install.sh | bash -s -- --prefix=/usr/local --with-extra-options --verbosecode = "curl -fsSL https://example.com/install.sh | bash -s -- --prefix=/usr/local --with-extra-options --verbose"
Codeblock(code, syntax: :shell, wrap: true)
Small
bundle add ruby_ui --group development --require falseCodeblock("bundle add ruby_ui --group development --require false", syntax: :shell, size: :sm)
Destructive
NameError: uninitialized constant User
app/controllers/users_controller.rb:4:in 'index'
code = <<~CODE
NameError: uninitialized constant User
app/controllers/users_controller.rb:4:in 'index'
CODE
Codeblock(variant: :destructive) do
CodeblockHeader { CodeblockTitle { "Error" } }
CodeblockContent(code)
end
Custom copy message
Copy the code to hear the message with a screen reader.
puts 'Hello'Codeblock("puts 'Hello'", syntax: :ruby, clipboard_success: "Nice one!")
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component Codeblock
Manual installation
1
Add RubyUI::Codeblock to app/components/ruby_ui/codeblock/codeblock.rb
# frozen_string_literal: true
require "rouge"
module RubyUI
# A block of code, styled after shadcn's CodeBlock (Base UI + vega).
#
# Shorthand: Codeblock(code, syntax: :ruby) renders the code highlighted by
# Rouge with a copy button in the top-right corner (clipboard: false drops
# it). Without code it is the root of a composition: CodeblockHeader,
# CodeblockTitle, CodeblockActions, CodeblockCopyButton, CodeblockContent,
# CodeblockLine and CodeblockFooter.
#
# Syntax colors come from CodeblockTheme, rendered once in the <head>.
class Codeblock < Base
VARIANTS = {
default: "border border-input text-foreground dark:bg-input/30 " \
"has-[>[data-slot=code-block-content]:focus-visible]:border-ring",
destructive: "border border-destructive/30 text-destructive dark:border-destructive/40"
}.freeze
SIZES = %i[default sm].freeze
# clipboard_error: is deprecated and ignored: a failed copy shows nothing.
def initialize(code = nil, syntax: nil, variant: :default, size: :default, clipboard: true,
clipboard_success: "Copied", clipboard_error: nil, wrap: false, line_numbers: false,
start_line: 1, **attrs)
unless clipboard_error.nil?
Warning.warn("RubyUI::Codeblock: `clipboard_error:` is deprecated and ignored " \
"(a failed copy shows nothing, as in shadcn)\n", category: :deprecated)
end
@code = code
@syntax = syntax
@variant = variant.to_s.to_sym
@variant = :default unless VARIANTS.key?(@variant)
@size = size.to_s.to_sym
@size = :default unless SIZES.include?(@size)
@clipboard = clipboard
@clipboard_success = clipboard_success
@content = {wrap: wrap, line_numbers: line_numbers, start_line: start_line}
super(**attrs)
end
def view_template(&block)
div(**attrs) do
@code.nil? ? block&.call : shorthand
end
end
private
def shorthand
if @clipboard
render CodeblockActions.new do
render CodeblockCopyButton.new(value: @code, copied_label: @clipboard_success)
end
end
render CodeblockContent.new(@code, syntax: @syntax, **@content)
end
def default_attrs
{
data: {slot: "code-block", variant: @variant, size: @size, controller: "ruby-ui--codeblock"},
class: [
"group/code-block relative flex w-full min-w-0 flex-col overflow-hidden rounded-lg text-sm data-[size=sm]:text-xs",
"has-[>[data-slot=code-block-content]:focus-visible]:ring-3 has-[>[data-slot=code-block-content]:focus-visible]:ring-ring/50",
VARIANTS.fetch(@variant)
]
}
end
end
end
2
Add RubyUI::CodeblockActions to app/components/ruby_ui/codeblock/codeblock_actions.rb
# frozen_string_literal: true
module RubyUI
# Buttons for the block. Inside a CodeblockHeader they sit at its end; as a
# direct child of Codeblock they float in the top-right corner and show on
# hover or focus (always on touch screens).
class CodeblockActions < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {slot: "code-block-actions"},
class: [
"ms-auto -me-1.5 -my-1 flex shrink-0 items-center gap-1",
"[[data-slot=code-block]>&]:absolute [[data-slot=code-block]>&]:end-3 [[data-slot=code-block]>&]:top-3 [[data-slot=code-block]>&]:z-10 [[data-slot=code-block]>&]:m-0",
"group-data-[size=sm]/code-block:[[data-slot=code-block]>&]:end-2 group-data-[size=sm]/code-block:[[data-slot=code-block]>&]:top-2",
"[[data-slot=code-block]>&]:opacity-0 [[data-slot=code-block]>&]:transition-opacity",
"[[data-slot=code-block]>&]:group-focus-within/code-block:opacity-100 [[data-slot=code-block]>&]:group-hover/code-block:opacity-100 [[data-slot=code-block]>&]:pointer-coarse:opacity-100",
"[[data-slot=code-block]>&]:*:[button]:bg-background [[data-slot=code-block]>&]:*:[button]:ring-1 [[data-slot=code-block]>&]:*:[button]:ring-foreground/10"
]
}
end
end
end
3
Add RubyUI::CodeblockContent to app/components/ruby_ui/codeblock/codeblock_content.rb
# frozen_string_literal: true
require "rouge"
module RubyUI
# The scrollable code: a focusable <pre> (so the keyboard can scroll it)
# with a <code> inside.
#
# Given code, it highlights it with Rouge (syntax: any Rouge lexer name;
# plain text without it) and, with line_numbers: true, renders one
# CodeblockLine per line, numbered from start_line. Without code, the block
# gives its own content, usually CodeblockLines (e.g. a diff with
# variant: :added / :removed). wrap: true wraps long lines instead of
# scrolling sideways.
class CodeblockContent < Base
FORMATTER = ::Rouge::Formatters::HTML.new
def initialize(code = nil, syntax: nil, wrap: false, line_numbers: false, start_line: 1, style: nil, **attrs)
@code = code
@style = style
@syntax = syntax
@wrap = wrap
@line_numbers = line_numbers
@start_line = start_line.to_i
super(**attrs)
end
def view_template(&block)
pre(**attrs) do
code(
data: {slot: "code-block-code"},
class: "grid min-h-lh min-w-max content-start in-data-numbered:grid-cols-[auto_1fr] in-data-wrap:min-w-0"
) do
if @code.nil?
block&.call
elsif @line_numbers
lines.each { |line| render CodeblockLine.new { raw(safe(line)) } }
else
# One grid item: Rouge's spans would each take a row of the grid.
span { raw(safe(FORMATTER.format(lexer.lex(@code)))) }
end
end
end
end
private
def lexer
(@syntax && ::Rouge::Lexer.find(@syntax.to_s)) || ::Rouge::Lexers::PlainText
end
# Highlighted HTML per line, without the trailing newline, so each line's
# spans close inside its CodeblockLine.
def lines
lines = [[]]
lexer.lex(@code.chomp).each do |token, value|
value.split(/(\n)/).each do |part|
if part == "\n"
lines << []
elsif !part.empty?
lines.last << [token, part]
end
end
end
lines.map { |tokens| FORMATTER.format(tokens) }
end
# One declaration list: Phlex's mix would join our string with the
# caller's style without a separator. The caller's style comes last, so it
# wins, as in shadcn ({counterReset, ...style}).
def style
own = "counter-reset: line #{@start_line - 1};"
theirs = @style.is_a?(Hash) ? @style.map { |k, v| "#{k.to_s.tr("_", "-")}: #{v};" }.join(" ") : @style.to_s.strip
theirs = "#{theirs};" unless theirs.empty? || theirs.end_with?(";")
[own, theirs].reject(&:empty?).join(" ")
end
def default_attrs
data = {slot: "code-block-content"}
data[:wrap] = true if @wrap
data[:numbered] = true if @line_numbers
{
dir: "ltr",
tabindex: 0,
style: style,
data: data,
class: [
"min-h-0 min-w-0 scroll-fade-x scrollbar-thin overflow-auto px-4 py-4 text-start font-mono leading-relaxed outline-none",
"data-wrap:scroll-fade-y data-wrap:wrap-break-word data-wrap:whitespace-pre-wrap",
"group-has-data-[slot=code-block-header]/code-block:pt-2 group-has-data-[slot=code-block-footer]/code-block:pb-2",
"group-data-[size=sm]/code-block:px-3 group-data-[size=sm]/code-block:py-3",
"group-data-[size=sm]/code-block:group-has-data-[slot=code-block-header]/code-block:pt-1.5"
]
}
end
end
end
4
Add RubyUI::CodeblockCopyButton to app/components/ruby_ui/codeblock/codeblock_copy_button.rb
# frozen_string_literal: true
module RubyUI
# Copies value: (or, without it, the block's code) to the clipboard. After
# copying, the icon turns into a check and the label into copied_label for
# two seconds; a polite live region announces it. A failed copy shows
# nothing, as in shadcn. Needs a Codeblock around it (its Stimulus
# controller lives on the root).
class CodeblockCopyButton < Base
# error_label: is deprecated and ignored: a failed copy shows nothing.
def initialize(value: nil, label: "Copy", copied_label: "Copied", error_label: nil,
variant: :ghost, size: :xs, **attrs)
unless error_label.nil?
Warning.warn("RubyUI::CodeblockCopyButton: `error_label:` is deprecated and ignored " \
"(a failed copy shows nothing, as in shadcn)\n", category: :deprecated)
end
@value = value
@label = label
@copied_label = copied_label
@variant = variant
@size = size
super(**attrs)
end
def view_template
button = Button.new(variant: @variant, size: @size, icon: true, **attrs)
# mix joins strings, so the Button's own slot would read "button code-block-copy-button".
button.attrs[:data][:slot] = "code-block-copy-button"
render button do
copy_icon
check_icon
end
span(aria_live: "polite", class: "sr-only", data: {slot: "code-block-copy-status"})
end
private
def default_attrs
data = {
slot: "code-block-copy-button",
action: "ruby-ui--codeblock#copy",
label: @label,
copied_label: @copied_label
}
data[:value] = @value unless @value.nil?
{aria: {label: @label}, data: data}
end
# lucide copy and check, as in shadcn; the button's data-copied picks one.
def copy_icon
icon(class: "group-data-copied/button:hidden") do |s|
s.rect(width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2")
s.path(d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2")
end
end
def check_icon
icon(class: "hidden group-data-copied/button:block") do |s|
s.path(d: "M20 6 9 17l-5-5")
end
end
def icon(**, &)
svg(
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewbox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
aria_hidden: "true",
**, &
)
end
end
end
5
Add RubyUI::CodeblockDocs to app/components/ruby_ui/codeblock/codeblock_docs.rb
# frozen_string_literal: true
class Views::Docs::Codeblock < Views::Base
def view_template
component = "Codeblock"
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Codeblock", description: "Displays a block of code with syntax highlighting and a copy button.")
Heading(level: 2) { "Setup" }
Text { "Syntax colors come from CodeblockTheme, Rouge's GitHub theme in light and dark. Render it once in the head of your layout: render RubyUI::CodeblockTheme.new" }
Heading(level: 2) { "Usage" }
render Docs::VisualCodeExample.new(title: "Example", context: self) do
<<~RUBY
code = <<~CODE
def hello_world
puts "Hello, world!"
end
CODE
Codeblock(code, syntax: :ruby)
RUBY
end
render Docs::VisualCodeExample.new(title: "Without copy button", context: self) do
<<~RUBY
Codeblock("bin/rails generate ruby_ui:component Codeblock", syntax: :shell, clipboard: false)
RUBY
end
render Docs::VisualCodeExample.new(title: "Line numbers", context: self) do
<<~RUBY
code = <<~CODE
class User < ApplicationRecord
has_many :posts
validates :email, presence: true
end
CODE
Codeblock(code, syntax: :ruby, line_numbers: true, start_line: 10)
RUBY
end
render Docs::VisualCodeExample.new(title: "Header and footer", context: self) do
<<~RUBY
code = <<~CODE
Rails.application.routes.draw do
resources :posts
end
CODE
Codeblock do
CodeblockHeader do
CodeblockTitle { "config/routes.rb" }
CodeblockActions do
CodeblockCopyButton(value: code)
end
end
CodeblockContent(code, syntax: :ruby)
CodeblockFooter { "Ruby" }
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Diff", description: "Mark lines with variant: :added, :removed or :highlighted.", context: self) do
<<~RUBY
gemfile = <<~GEMFILE
source "https://rubygems.org"
gem "ruby_ui", "~> 1.6"
gem "rouge"
GEMFILE
Codeblock do
CodeblockActions do
CodeblockCopyButton(value: gemfile)
end
CodeblockContent do
CodeblockLine { "source 'https://rubygems.org'" }
CodeblockLine()
CodeblockLine(variant: :removed) { "gem 'ruby_ui', '~> 1.5'" }
CodeblockLine(variant: :added) { "gem 'ruby_ui', '~> 1.6'" }
CodeblockLine(variant: :highlighted) { "gem 'rouge'" }
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Wrap", description: "Long lines wrap instead of scrolling sideways.", context: self) do
<<~RUBY
code = "curl -fsSL https://example.com/install.sh | bash -s -- --prefix=/usr/local --with-extra-options --verbose"
Codeblock(code, syntax: :shell, wrap: true)
RUBY
end
render Docs::VisualCodeExample.new(title: "Small", context: self) do
<<~RUBY
Codeblock("bundle add ruby_ui --group development --require false", syntax: :shell, size: :sm)
RUBY
end
render Docs::VisualCodeExample.new(title: "Destructive", context: self) do
<<~RUBY
code = <<~CODE
NameError: uninitialized constant User
app/controllers/users_controller.rb:4:in 'index'
CODE
Codeblock(variant: :destructive) do
CodeblockHeader { CodeblockTitle { "Error" } }
CodeblockContent(code)
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Custom copy message", description: "Copy the code to hear the message with a screen reader.", context: self) do
<<~RUBY
Codeblock("puts 'Hello'", syntax: :ruby, clipboard_success: "Nice one!")
RUBY
end
render Components::ComponentSetup::Tabs.new(component_name: component)
render Docs::ComponentsTable.new(component_files(component))
end
end
end
6
Add RubyUI::CodeblockFooter to app/components/ruby_ui/codeblock/codeblock_footer.rb
# frozen_string_literal: true
module RubyUI
# A row below the code, for a caption or extra actions.
class CodeblockFooter < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {slot: "code-block-footer"},
class: [
"flex min-w-0 shrink-0 items-center gap-2 px-4 pb-4 text-muted-foreground",
"group-data-[size=sm]/code-block:px-3 group-data-[size=sm]/code-block:pb-3"
]
}
end
end
end
7
Add RubyUI::CodeblockHeader to app/components/ruby_ui/codeblock/codeblock_header.rb
# frozen_string_literal: true
module RubyUI
# A row above the code: a CodeblockTitle and, on the right, CodeblockActions.
class CodeblockHeader < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {slot: "code-block-header"},
class: [
"flex min-w-0 shrink-0 items-center gap-2 px-4 pt-4 text-muted-foreground *:has-[>[data-slot=code-block-title]]:min-w-0",
"group-data-[variant=destructive]/code-block:text-destructive",
"group-data-[size=sm]/code-block:px-3 group-data-[size=sm]/code-block:pt-3"
]
}
end
end
end
8
Add RubyUI::CodeblockLine to app/components/ruby_ui/codeblock/codeblock_line.rb
# frozen_string_literal: true
module RubyUI
# One line of code. Numbered when its CodeblockContent has line_numbers:
# true. variant: :added / :removed mark a diff (+ / − in the gutter),
# :highlighted shades the line.
class CodeblockLine < Base
VARIANTS = {
default: nil,
added: "bg-muted after:content-['+']",
removed: "bg-destructive/10 after:text-destructive after:content-['−']",
highlighted: "bg-muted"
}.freeze
def initialize(variant: :default, **attrs)
@variant = variant.to_s.to_sym
@variant = :default unless VARIANTS.key?(@variant)
super(**attrs)
end
# An empty line holds a newline so copying a selection keeps blank lines.
def view_template(&block)
span(**attrs) do
span(data: {slot: "code-block-line-content"}, class: "min-w-0") do
content = capture(&block) if block
content.to_s.empty? ? plain("\n") : raw(safe(content))
end
end
end
private
def default_attrs
{
data: {slot: "code-block-line", variant: @variant},
class: [
"relative -mx-4 block min-h-lh px-4 group-data-[size=sm]/code-block:-mx-3 group-data-[size=sm]/code-block:px-3",
"after:absolute after:start-1 after:top-0 after:text-muted-foreground after:select-none",
"in-data-numbered:col-span-2 in-data-numbered:grid in-data-numbered:grid-cols-subgrid in-data-numbered:gap-x-3 in-data-numbered:[counter-increment:line]",
"before:min-w-6 before:text-end before:text-muted-foreground",
"in-data-numbered:before:content-[counter(line)] in-data-numbered:before:select-none",
"in-data-numbered:supports-[content:counter(line)_/_'']:before:content-[counter(line)_/_'']",
VARIANTS.fetch(@variant)
]
}
end
end
end
9
Add RubyUI::CodeblockTheme to app/components/ruby_ui/codeblock/codeblock_theme.rb
# frozen_string_literal: true
require "rouge"
module RubyUI
# Syntax colors for every Codeblock: Rouge's GitHub theme, light by default
# and dark under .dark. Render it once, in the layout's <head>. Plain text
# keeps the block's text color (the theme's base color and background are
# left out).
class CodeblockTheme < Base
SCOPE = "[data-slot=code-block-code]"
def self.rouge_css(mode, scope)
quoted = Regexp.escape(scope)
::Rouge::Themes::Github.mode(mode).render(scope: scope)
.gsub(/^#{quoted} table[^\n]*\n/, "")
.sub(/^#{quoted}, #{quoted} \.w \{[^}]*\}\n/, "")
end
CSS = [rouge_css(:light, SCOPE), rouge_css(:dark, ".dark #{SCOPE}")].join("\n").freeze
def view_template
style(**attrs) { raw(safe(CSS)) }
end
end
end
10
Add RubyUI::CodeblockTitle to app/components/ruby_ui/codeblock/codeblock_title.rb
# frozen_string_literal: true
module RubyUI
# The file name (and an optional icon) in a CodeblockHeader.
class CodeblockTitle < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {slot: "code-block-title"},
class: [
"min-w-0 truncate font-sans text-sm font-medium text-foreground",
"[&_svg]:pointer-events-none [&_svg]:inline-block [&_svg]:align-middle *:[svg]:me-1.5 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=destructive]/code-block:text-destructive",
"group-data-[size=sm]/code-block:text-xs group-data-[size=sm]/code-block:[&_svg:not([class*='size-'])]:size-3.5"
]
}
end
end
end
11
Add codeblock_controller.js to app/javascript/controllers/ruby_ui/codeblock_controller.js
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="ruby-ui--codeblock"
// Copies a CodeblockCopyButton's data-value (or the block's code), then shows
// the copied state for two seconds: data-copied, the copied label as
// aria-label and in the live region next to the button. A failed copy shows
// nothing, as in shadcn.
export default class extends Controller {
static RESET_AFTER = 2000;
connect() {
this.timers = new Map();
// A cached page (Turbo snapshot) can come back mid-feedback: start clean.
this.buttons().forEach((button) => this.reset(button));
}
disconnect() {
this.timers.forEach((timer, button) => {
clearTimeout(timer);
this.reset(button);
});
this.timers.clear();
}
copy(event) {
const button = event.currentTarget;
const value = button.dataset.value ?? this.code();
// No Clipboard API outside a secure context (plain http).
if (!navigator.clipboard?.writeText) return;
navigator.clipboard
.writeText(value)
.then(() => this.show(button))
.catch(() => {});
}
code() {
return this.element.querySelector("[data-slot=code-block-code]")?.innerText ?? "";
}
show(button) {
const label = button.dataset.copiedLabel;
clearTimeout(this.timers.get(button));
button.dataset.copied = "true";
button.setAttribute("aria-label", label);
this.status(button, label);
this.timers.set(button, setTimeout(() => {
this.timers.delete(button);
this.reset(button);
}, this.constructor.RESET_AFTER));
}
reset(button) {
delete button.dataset.copied;
button.setAttribute("aria-label", button.dataset.label);
this.status(button, "");
}
buttons() {
return this.element.querySelectorAll("[data-slot=code-block-copy-button]");
}
status(button, text) {
const status = button.nextElementSibling;
if (status?.dataset.slot === "code-block-copy-status") status.textContent = text;
}
}
12
Update the Stimulus controllers manifest file
rake stimulus:manifest:update13
Install rouge Ruby gem
bundle add rouge
14
Install required components
Component Codeblock relies on the following RubyUI components. Refer to their individual installation guides for setup instructions:
Components
| Component | Built using | Source |
|---|---|---|
Codeblock | Phlex | |
CodeblockActions | Phlex | |
CodeblockContent | Phlex | |
CodeblockCopyButton | Phlex | |
CodeblockDocs | Phlex | |
CodeblockFooter | Phlex | |
CodeblockHeader | Phlex | |
CodeblockLine | Phlex | |
CodeblockTheme | Phlex | |
CodeblockTitle | Phlex | |
CodeblockController | Stimulus JS |