feat(read): rendered README + syntax-highlighted blob view

Make the web repo-browsing experience render code and Markdown instead of plain text — the core "calm place to read code" promise. Markdown (MarkdownRenderer service): - GFM via Redcarpet: tables, task lists, strikethrough, autolinks, footnotes - Rouge-highlighted fenced code; heading anchor links with unique slugs - relative links/images resolved against the repo + current ref - allow-list HTML sanitization (rails-html-sanitizer) + pruning of script/style/iframe blocks: README HTML/JS can never run on the app origin Blob view: - per-line Rouge highlighting extracted to SyntaxHighlighter, cached by blob SHA - large-file handling: skip highlight past 512KB, truncate display past 5k lines, never load files >5MB (view-raw escape hatch); binary/image preview - .md blobs render as Markdown by default, ?plain=1 shows source - line selection: click → #L120, shift-click → #L120-L140, highlight + URL sync + scroll-on-load; "Copy permalink" pinned to the commit SHA - copy buttons on code + a Clone HTTPS/SSH control on the repo page Raw endpoint: X-Content-Type-Options: nosniff so user content (HTML/SVG) can't execute as active content; images keep their real content-type. Specs (RSpec, matching the project convention): renderer/sanitizer, highlighter, the raw endpoint, and the rendered read experience end-to-end. brakeman.ignore documents the new arg-list Open3 calls as shell-safe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 30, 2026 at 19:18 UTC 89ccf9fe16d4a300d7f72d2ddad1a28aee31d93b
16 files changed +1037 -72
app/assets/stylesheets/application.tailwind.css
+30
index 983bff2..7f8c0ab 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -99,6 +99,36 @@ .prose-readme blockquote { @apply border-l-4 border-surface-500 pl-4 italic text-gray-400 my-4; } .prose-readme hr { @apply border-surface-600 my-6; } .prose-readme img { @apply max-w-full; } + .prose-readme h4 { @apply text-base font-medium mb-2 mt-4; } + .prose-readme h5 { @apply text-sm font-medium mb-2 mt-3; } + .prose-readme h6 { @apply text-sm font-medium text-gray-400 mb-2 mt-3; } + /* Highlighted fenced code from MarkdownRenderer renders as <pre class="highlight"> */ + .prose-readme pre.highlight { @apply p-4; } + .prose-readme .task-list-item { @apply list-none -ml-6; } + .prose-readme .task-list-item input { @apply mr-1.5 align-middle; } + /* GitHub-style heading anchors: the "#" link is hidden until hover. */ + .prose-readme .heading-anchor { + @apply opacity-0 no-underline text-gray-500 mr-2 -ml-6 inline-block w-4; + transition: opacity 0.1s; + } + .prose-readme .heading-anchored:hover .heading-anchor { @apply opacity-100; } + + /* Blob view line selection */ + .line-row:target, + .line-row.line-selected { @apply bg-brand-500/15; } + .line-row:hover { @apply bg-brand-500/10; } + + .clone-menu > summary::-webkit-details-marker { display: none; } + .btn-ghost.copied { @apply text-green-400; } + + /* Copy button injected into rendered Markdown code blocks */ + .code-copy-wrap { @apply relative; } + .code-copy-btn { + @apply absolute top-2 right-2 opacity-0 bg-surface-600 border border-surface-500 + rounded px-2 py-0.5 text-xs text-gray-300 hover:text-gray-100 transition-opacity; + } + .code-copy-wrap:hover .code-copy-btn { @apply opacity-100; } + .code-copy-btn.copied { @apply text-green-400 opacity-100; } .diff-add { @apply bg-green-900/30 text-green-300; } .diff-remove { @apply bg-red-900/30 text-red-300; }
app/controllers/blobs_controller.rb
+104 -45
index 234dfc0..893e801 100644 --- a/app/controllers/blobs_controller.rb +++ b/app/controllers/blobs_controller.rb @@ -16,6 +16,18 @@ class BlobsController < ApplicationController # is loaded through an <img> tag, which browsers sandbox. PREVIEW_IMAGE_TYPES = RAW_IMAGE_TYPES.merge("svg" => "image/svg+xml").freeze + MARKDOWN_EXTENSIONS = %w[md markdown mdown mkd].freeze + + # Above this many bytes a text file is shown as plain (un-highlighted) text: + # tokenizing megabyte-scale files is the main source of render jank. + MAX_HIGHLIGHT_BYTES = 512 * 1024 + # Above this many bytes we don't render Markdown — show source instead. + MAX_MARKDOWN_BYTES = 512 * 1024 + # Above this we don't read the blob into memory at all; only "view raw" works. + MAX_LOAD_BYTES = 5 * 1024 * 1024 + # Display cap so a huge file never renders tens of thousands of <tr>s. + MAX_DISPLAY_LINES = 5_000 + def show # A file path like ".../app.js" or "...style.css" makes Rails negotiate a # non-HTML format from the trailing extension — which has no template (→ @@ -29,52 +41,125 @@ class BlobsController < ApplicationController @branch = params[:branch] @file_path = params[:path] @path_parts = @file_path.to_s.split("/").reject(&:blank?) + @filename = File.basename(@file_path) + @extension = File.extname(@filename).delete_prefix(".").downcase + @plain = params[:plain].present? - content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path) - if content.nil? + @blob_sha = GitRepositoryService.blob_sha(@repository.disk_path, @branch, @file_path) + @file_size = GitRepositoryService.blob_size(@repository.disk_path, @branch, @file_path) + if @blob_sha.nil? || @file_size.nil? render file: Rails.public_path.join("404.html"), status: :not_found, layout: false return end - @raw_content = content - @filename = File.basename(@file_path) - @extension = File.extname(@filename).delete_prefix(".") @commit_count = GitRepositoryService.commit_count(@repository.disk_path, @branch) - @is_binary = content.encoding != Encoding::UTF_8 || !content.valid_encoding? || binary_content?(content) - @file_size = content.bytesize + # Pin "copy permalink" to the commit the ref points at right now, so the + # link keeps resolving to this exact content after the branch moves on. + @commit_sha = GitRepositoryService.commit_sha(@repository.disk_path, @branch) + @raw_path = repository_blob_raw_path(@owner.username, @repository.name, @branch, @file_path) - @image_type = PREVIEW_IMAGE_TYPES[@extension.downcase] + @image_type = PREVIEW_IMAGE_TYPES[@extension] if @image_type - # Embed the image inline as a data URI so it previews without a second - # request. <img>-loaded content is sandboxed, so this is safe for SVG too. - @image_data_uri = "data:#{@image_type};base64,#{[ content ].pack('m0')}" - elsif !@is_binary - @highlighted_lines = highlight_lines(content, @filename) - @line_count = content.lines.count + prepare_image + else + prepare_text end render :show end def raw - @branch = params[:branch] - @file_path = params[:path] + branch = params[:branch] + file_path = params[:path] - content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path) + content = GitRepositoryService.file_content(@repository.disk_path, branch, file_path) if content.nil? head :not_found return end - ext = File.extname(@file_path).delete_prefix(".").downcase + ext = File.extname(file_path).delete_prefix(".").downcase + # nosniff stops the browser from re-interpreting user content (e.g. an HTML + # or SVG file served as text/plain) as active content on the app origin. + response.headers["X-Content-Type-Options"] = "nosniff" send_data content, type: RAW_IMAGE_TYPES[ext] || "text/plain; charset=utf-8", disposition: "inline", - filename: File.basename(@file_path) + filename: File.basename(file_path) end private + def prepare_image + if @file_size > MAX_LOAD_BYTES + @too_large = true + return + end + content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path) + # Embed inline as a data URI so it previews without a second request. + # <img>-loaded content is sandboxed, so this is safe for SVG too. + @image_data_uri = "data:#{@image_type};base64,#{[ content ].pack('m0')}" + end + + def prepare_text + if @file_size > MAX_LOAD_BYTES + @too_large = true + return + end + + content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path) + if content.nil? + render file: Rails.public_path.join("404.html"), status: :not_found, layout: false + return + end + + @is_binary = content.encoding != Encoding::UTF_8 || !content.valid_encoding? || binary_content?(content) + return if @is_binary + + if markdown? && !@plain && @file_size <= MAX_MARKDOWN_BYTES + @markdown_html = render_markdown(content) + return + end + + @line_count = content.lines.count + lines = highlighted_lines(content) + if lines.length > MAX_DISPLAY_LINES + @truncated = true + lines = lines.first(MAX_DISPLAY_LINES) + end + @highlighted_lines = lines + end + + def markdown? + MARKDOWN_EXTENSIONS.include?(@extension) + end + + # Highlighted line fragments, cached by blob SHA (content-addressed, so the + # cache is shared across refs and never goes stale). Files past the highlight + # budget are escaped as plain text to keep large views snappy. + def highlighted_lines(content) + if @file_size > MAX_HIGHLIGHT_BYTES + @highlight_skipped = true + return content.lines.map { |l| ERB::Util.html_escape(l.chomp) }.map(&:html_safe) + end + + Rails.cache.fetch([ "blob-hl", @blob_sha ]) do + SyntaxHighlighter.lines(content, filename: @filename) + end.map(&:html_safe) + end + + def render_markdown(content) + context = MarkdownRenderer::Context.new( + username: @owner.username, + repository: @repository.name, + ref: @branch, + dir: @path_parts[0..-2].join("/") + ) + Rails.cache.fetch([ "md", @blob_sha, context.ref, context.dir ]) do + MarkdownRenderer.render(content, context: context) + end.html_safe + end + def load_owner @owner = User.find_by!(username: params[:username]) rescue ActiveRecord::RecordNotFound @@ -96,30 +181,4 @@ class BlobsController < ApplicationController def binary_content?(content) content.bytes.first(8192).include?(0) end - - def highlight_lines(content, filename) - lexer = Rouge::Lexer.guess(filename: filename, source: content) - html_formatter = Rouge::Formatters::HTML.new - - # Tokenize the entire file at once (preserves stateful lexer context across - # lines — e.g. multiline strings, heredocs) then split the token stream on - # newline boundaries to get one HTML fragment per source line. - lines = [[]] - lexer.lex(content).each do |token, value| - value.split(/(\n)/, -1).each do |part| - if part == "\n" - lines << [] - else - lines.last << [token, part] unless part.empty? - end - end - end - - # Drop a trailing empty entry when the file ends with a newline. - lines.pop if lines.last&.empty? - - lines.map { |line_tokens| html_formatter.format(line_tokens).html_safe } - rescue StandardError - content.lines.map { |l| ERB::Util.html_escape(l.chomp).html_safe } - end end
app/controllers/repositories_controller.rb
+24 -17
index e04d44c..8a062c4 100644 --- a/app/controllers/repositories_controller.rb +++ b/app/controllers/repositories_controller.rb @@ -69,9 +69,11 @@ class RepositoriesController < ApplicationController @tree = GitRepositoryService.list_tree(@repository.disk_path, branch) readme = GitRepositoryService.readme_content(@repository.disk_path, branch) if readme - @readme_html = render_markdown(readme[:content]) + @readme_html = render_markdown(readme[:content], file_path: readme[:filename], branch: branch) @readme_filename = readme[:filename] end + @clone_https_url = clone_https_url(@owner.username, @repository.name) + @clone_ssh_url = clone_ssh_url(@owner.username, @repository.name) @commit_count = GitRepositoryService.commit_count(@repository.disk_path, branch) @recent_commits = GitRepositoryService.commits(@repository.disk_path, branch, limit: 3) end @@ -136,7 +138,7 @@ class RepositoriesController < ApplicationController # client-side tab switching. def show_model(branch) @card = @repository.model_card(branch: branch) - @card_html = render_markdown(@card.body) if @card.body.present? + @card_html = render_markdown(@card.body, file_path: "README.md", branch: branch) if @card.body.present? @files = GitRepositoryService.model_files(@repository.disk_path, branch) @total_size = @files.filter_map { |f| f[:size] }.sum @commit_count = GitRepositoryService.commit_count(@repository.disk_path, branch) @@ -199,21 +201,26 @@ class RepositoriesController < ApplicationController end end - def render_markdown(text) - renderer = Redcarpet::Render::HTML.new( - hard_wrap: false, - link_attributes: { target: "_blank", rel: "noopener noreferrer" }, - no_images: false, - safe_links_only: true + # Render Markdown to sanitized HTML, resolving relative links/images against + # this repo at the current ref. Cached by the file's blob SHA + ref so the + # heavy render only runs when the content (or relative-link base) changes. + def render_markdown(text, file_path:, branch:) + context = MarkdownRenderer::Context.new( + username: @owner.username, + repository: @repository.name, + ref: branch, + dir: File.dirname(file_path.to_s).then { |d| d == "." ? "" : d } ) - markdown = Redcarpet::Markdown.new( - renderer, - autolink: true, - tables: true, - fenced_code_blocks: true, - strikethrough: true, - space_after_headers: true - ) - markdown.render(text).html_safe + sha = GitRepositoryService.blob_sha(@repository.disk_path, branch, file_path) + cache_key = [ "md", sha || Digest::SHA1.hexdigest(text.to_s), context.username, context.repository, context.ref, context.dir ] + Rails.cache.fetch(cache_key) { MarkdownRenderer.render(text, context: context) }.html_safe + end + + def clone_https_url(username, reponame) + "#{request.base_url}/#{username}/#{reponame}.git" + end + + def clone_ssh_url(username, reponame) + "git@#{request.host}:#{username}/#{reponame}.git" end end
app/javascript/controllers/clipboard_controller.js
+59
new file mode 100644 index 0000000..89114d9 --- /dev/null +++ b/app/javascript/controllers/clipboard_controller.js @@ -0,0 +1,59 @@ +import { Controller } from "@hotwired/stimulus" + +// Copies text to the clipboard and flashes a "Copied" confirmation. +// +// Source of the text, in priority order: +// 1. data-clipboard-text-value on the controller element +// 2. the textContent of the element matched by the "source" target +// +// <div data-controller="clipboard" data-clipboard-text-value="git clone …"> +// <button data-action="clipboard#copy">Copy</button> +// </div> +export default class extends Controller { + static targets = ["button", "source"] + static values = { text: String, label: { type: String, default: "Copied" } } + + copy(event) { + event.preventDefault() + let text = "" + if (this.hasTextValue && this.textValue) { + text = this.textValue + } else if (this.sourceTargets.length) { + // Multiple sources (e.g. one per code line) join with newlines so a whole + // file copies back as the original text. + text = this.sourceTargets.map((el) => el.textContent).join("\n") + } + if (!text) return + + this.#write(text).then(() => this.#flash(event.currentTarget)) + } + + async #write(text) { + try { + await navigator.clipboard.writeText(text) + } catch { + // Fallback for non-secure contexts where the async clipboard API is absent. + const ta = document.createElement("textarea") + ta.value = text + ta.style.position = "fixed" + ta.style.opacity = "0" + document.body.appendChild(ta) + ta.select() + try { document.execCommand("copy") } catch { /* no-op */ } + ta.remove() + } + } + + #flash(button) { + if (!button) return + const original = button.dataset.originalLabel ?? button.textContent + button.dataset.originalLabel = original + button.textContent = this.labelValue + button.classList.add("copied") + clearTimeout(this._timer) + this._timer = setTimeout(() => { + button.textContent = original + button.classList.remove("copied") + }, 1500) + } +}
app/javascript/controllers/code_copy_controller.js
+36
new file mode 100644 index 0000000..b66fcf3 --- /dev/null +++ b/app/javascript/controllers/code_copy_controller.js @@ -0,0 +1,36 @@ +import { Controller } from "@hotwired/stimulus" + +// Injects a "Copy" button into every fenced code block inside rendered +// Markdown (README / .md). Attach to the prose container: +// <div class="prose-readme" data-controller="code-copy">…</div> +export default class extends Controller { + connect() { + this.element.querySelectorAll("pre.highlight").forEach((pre) => { + if (pre.querySelector(".code-copy-btn")) return + pre.classList.add("code-copy-wrap") + + const btn = document.createElement("button") + btn.type = "button" + btn.className = "code-copy-btn" + btn.textContent = "Copy" + btn.addEventListener("click", () => this.#copy(pre, btn)) + pre.appendChild(btn) + }) + } + + async #copy(pre, btn) { + const code = pre.querySelector("code")?.textContent ?? pre.textContent + try { + await navigator.clipboard.writeText(code) + } catch { + return + } + const original = btn.textContent + btn.textContent = "Copied" + btn.classList.add("copied") + setTimeout(() => { + btn.textContent = original + btn.classList.remove("copied") + }, 1500) + } +}
app/javascript/controllers/line_selection_controller.js
+105
new file mode 100644 index 0000000..edbf5dc --- /dev/null +++ b/app/javascript/controllers/line_selection_controller.js @@ -0,0 +1,105 @@ +import { Controller } from "@hotwired/stimulus" + +// GitHub-style line selection for the blob view. +// +// click a line number -> #L120 +// shift-click another -> #L120-L140 (range, in either direction) +// load with a #L.. hash -> highlight + scroll into view +// "copy permalink" action -> SHA-pinned URL + the current selection +// +// Markup contract: +// <table data-controller="line-selection" +// data-line-selection-permalink-base-value="/u/r/blob/<sha>/path"> +// <tr id="L1" data-line-selection-target="row"> … <a data-action="…">1</a> … +export default class extends Controller { + static targets = ["row"] + static values = { permalinkBase: String } + + connect() { + this.start = null + this.end = null + this.#applyFromHash() + this._onHashChange = () => this.#applyFromHash() + window.addEventListener("hashchange", this._onHashChange) + } + + disconnect() { + window.removeEventListener("hashchange", this._onHashChange) + } + + // Bound to each line-number anchor via data-action="line-selection#select". + select(event) { + event.preventDefault() + const line = Number(event.params.line) + if (!line) return + + if (event.shiftKey && this.start) { + this.end = line + } else { + this.start = line + this.end = line + } + this.#render() + this.#updateHash() + } + + copyPermalink(event) { + event.preventDefault() + const base = this.permalinkBaseValue + if (!base) return + const origin = window.location.origin + const url = `${origin}${base}${this.#hash()}` + navigator.clipboard?.writeText(url).catch(() => {}) + this.#flash(event.currentTarget) + } + + #applyFromHash() { + const m = window.location.hash.match(/^#L(\d+)(?:-L?(\d+))?$/) + if (!m) return + this.start = Number(m[1]) + this.end = m[2] ? Number(m[2]) : this.start + this.#render() + const first = this.#row(Math.min(this.start, this.end)) + if (first) first.scrollIntoView({ block: "center" }) + } + + #render() { + const [lo, hi] = this.#ordered() + this.rowTargets.forEach((row) => { + const n = this.#lineOf(row) + row.classList.toggle("line-selected", lo !== null && n >= lo && n <= hi) + }) + } + + #updateHash() { + history.replaceState(null, "", this.#hash()) + } + + #hash() { + const [lo, hi] = this.#ordered() + if (lo === null) return "" + return lo === hi ? `#L${lo}` : `#L${lo}-L${hi}` + } + + #ordered() { + if (!this.start) return [null, null] + return [Math.min(this.start, this.end), Math.max(this.start, this.end)] + } + + #row(line) { + return this.rowTargets.find((r) => this.#lineOf(r) === line) + } + + #lineOf(row) { + return Number(row.dataset.line ?? row.id.replace(/^L/, "")) + } + + #flash(el) { + if (!el) return + const original = el.dataset.originalLabel ?? el.textContent + el.dataset.originalLabel = original + el.textContent = "Copied" + clearTimeout(this._t) + this._t = setTimeout(() => { el.textContent = original }, 1500) + } +}
app/services/git_repository_service.rb
+21
index e673ad0..536b557 100644 --- a/app/services/git_repository_service.rb +++ b/app/services/git_repository_service.rb @@ -87,6 +87,27 @@ class GitRepositoryService out end + # Object id of the blob at <ref>:<file_path>. Stable for identical content, so + # it's the natural cache key for rendered/highlighted output. nil if missing. + def self.blob_sha(path, branch, file_path) + out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "--quiet", "#{branch}:#{file_path}") + status.success? ? out.strip.presence : nil + end + + # Byte size of the blob without loading it into memory — lets the blob view + # decide up front whether a file is too large to highlight or render. + def self.blob_size(path, branch, file_path) + out, _err, status = Open3.capture3("git", "--git-dir", path, "cat-file", "-s", "#{branch}:#{file_path}") + status.success? ? out.strip.to_i : nil + end + + # Full commit SHA a ref currently points at. Permalinks pin to this so they + # survive the branch moving on. + def self.commit_sha(path, ref) + out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "--quiet", "#{ref}^{commit}") + status.success? ? out.strip.presence : nil + end + def self.commits(path, branch, limit: 20, offset: 0) format = "%H%x00%h%x00%s%x00%an%x00%ae%x00%ad%x00%cn" out, _err, status = Open3.capture3(
app/services/markdown_renderer.rb
+206
new file mode 100644 index 0000000..d511f71 --- /dev/null +++ b/app/services/markdown_renderer.rb @@ -0,0 +1,206 @@ +require "redcarpet" +require "rouge" +require "rails-html-sanitizer" +require "cgi" + +# Renders GitHub-Flavored Markdown to sanitized HTML for README/.md display. +# +# Pipeline: Redcarpet (with a Rouge-highlighting, anchor-adding renderer) → +# task-list post-processing → allow-list sanitization. The sanitizer is the +# trust boundary: nothing the README author writes can introduce <script>, +# <iframe>, inline event handlers, or `javascript:` URLs, so rendered READMEs +# can never run as active content on the app origin. +# +# When a +context+ is supplied (owner/repo/ref/dir), relative links and images +# are resolved against the repository at the current ref — links point at the +# blob view, images at the raw endpoint. +class MarkdownRenderer + Context = Struct.new(:username, :repository, :ref, :dir, keyword_init: true) + + # Tags we knowingly emit. Anything else is stripped to text. + ALLOWED_TAGS = %w[ + h1 h2 h3 h4 h5 h6 p br hr blockquote + ul ol li dl dt dd + strong em b i del s strike code pre span tt kbd sub sup + a img + table thead tbody tfoot tr th td + input + details summary div + ].freeze + + ALLOWED_ATTRIBUTES = %w[ + href src alt title class id name + align colspan rowspan start + type checked disabled value + aria-hidden rel target loading + ].freeze + + def self.render(text, context: nil) + new(context: context).render(text) + end + + def initialize(context: nil) + @context = context + end + + def render(text) + return "" if text.blank? + + html = engine.render(text.to_s) + html = prune_dangerous_blocks(html) + html = convert_task_lists(html) + sanitize(html) + end + + private + + # Raw inline HTML in Markdown flows through Redcarpet untouched. The allow-list + # sanitizer below removes disallowed *tags* but keeps their text children, so a + # <script> would leave its code behind as visible (inert) text. Drop these + # blocks wholesale first so nothing leaks through — even as text. + PRUNE_BLOCKS = %r{<(script|style|noscript|template|svg|math|head|title|object|embed)\b[^>]*>.*?</\1>}mi + + def prune_dangerous_blocks(html) + html.gsub(PRUNE_BLOCKS, "") + end + + def engine + @engine ||= Redcarpet::Markdown.new( + Renderer.new(context: @context), + autolink: true, + tables: true, + fenced_code_blocks: true, + strikethrough: true, + superscript: true, + footnotes: true, + highlight: true, + space_after_headers: true, + no_intra_emphasis: true + ) + end + + # GitHub-style task lists. Redcarpet renders "- [ ] foo" as a plain list item + # ("[ ] foo"); rewrite the leading marker into a disabled checkbox. Handles + # both tight ("<li>[ ] x") and loose ("<li>\n<p>[ ] x") list rendering. + def convert_task_lists(html) + html.gsub(%r{(<li>\s*(?:<p>\s*)?)\[([ xX])\]\s}) do + prefix = Regexp.last_match(1) + checked = Regexp.last_match(2) != " " + box = %(<input type="checkbox" disabled#{checked ? ' checked' : ''}> ) + %(#{prefix.sub('<li>', '<li class="task-list-item">')}#{box}) + end + end + + def sanitize(html) + sanitizer.sanitize( + html, + tags: ALLOWED_TAGS, + attributes: ALLOWED_ATTRIBUTES + ) + end + + def sanitizer + @sanitizer ||= + if defined?(Rails::HTML5::SafeListSanitizer) + Rails::HTML5::SafeListSanitizer.new + else + Rails::HTML4::SafeListSanitizer.new + end + end + + # Redcarpet HTML renderer that adds server-side syntax highlighting to fenced + # code, anchor links to headings, and repository-relative URL resolution. + class Renderer < Redcarpet::Render::HTML + def initialize(context: nil) + @context = context + @heading_slugs = Hash.new(0) + # safe_links_only blocks dangerous schemes (javascript:, data: …) at the + # render layer too; sanitization is still the authoritative gate. + super(safe_links_only: true, link_attributes: { rel: "nofollow noopener noreferrer" }) + end + + def block_code(code, language) + lexer = + (Rouge::Lexer.find(language.to_s.split.first) if language.present?) || + Rouge::Lexer.guess(source: code) rescue Rouge::Lexers::PlainText + lexer ||= Rouge::Lexers::PlainText + + formatter = Rouge::Formatters::HTML.new + inner = formatter.format(lexer.lex(code)) + lang_class = language.present? ? %( data-language="#{CGI.escapeHTML(language)}") : "" + %(<pre class="highlight code-block"#{lang_class}><code>#{inner}</code></pre>) + end + + def header(text, level) + slug = slugify(text) + level = level.clamp(1, 6) + <<~HTML.strip + <h#{level} id="#{slug}" class="heading-anchored"><a class="heading-anchor" href="##{slug}" aria-hidden="true">#</a>#{text}</h#{level}> + HTML + end + + def link(url, title, content) + resolved = resolve(url, raw: false) + title_attr = title.present? ? %( title="#{CGI.escapeHTML(title)}") : "" + %(<a href="#{CGI.escapeHTML(resolved)}"#{title_attr} rel="nofollow noopener noreferrer">#{content}</a>) + end + + def image(url, title, alt) + resolved = resolve(url, raw: true) + alt_attr = %( alt="#{CGI.escapeHTML(alt.to_s)}") + title_attr = title.present? ? %( title="#{CGI.escapeHTML(title)}") : "" + %(<img src="#{CGI.escapeHTML(resolved)}"#{alt_attr}#{title_attr} loading="lazy">) + end + + private + + # Map a heading's inline HTML to a GitHub-compatible anchor slug, keeping + # successive duplicates unique (foo, foo-1, foo-2 …). + def slugify(text) + base = text.gsub(/<[^>]+>/, "") # drop inline tags + .downcase + .gsub(/[^\w\- ]/, "") # drop punctuation + .strip + .gsub(/\s+/, "-") + base = "section" if base.empty? + n = @heading_slugs[base] + @heading_slugs[base] += 1 + n.zero? ? base : "#{base}-#{n}" + end + + # Resolve a possibly-relative link/image against the repo at the current + # ref. Absolute URLs, anchors, and root-relative paths are left untouched. + def resolve(url, raw:) + url = url.to_s + return url if @context.nil? || url.empty? + return url if url.start_with?("#", "/", "mailto:") + return url if url =~ %r{\A[a-z][a-z0-9+.\-]*://}i # has a scheme + + path = clean_path(url) + return url if path.nil? + + kind = raw ? "raw" : "blob" + "/#{@context.username}/#{@context.repository}/#{kind}/#{@context.ref}/#{path}" + end + + # Join the link against the current file's directory and collapse "."/".." + # without escaping the repository root. + def clean_path(url) + anchor = url[/#.*\z/] + url = url.sub(/#.*\z/, "") + segments = [] + base = @context.dir.to_s.split("/").reject(&:empty?) + (base + url.split("/")).each do |seg| + next if seg.empty? || seg == "." + if seg == ".." + return nil if segments.empty? + segments.pop + else + segments << seg + end + end + return nil if segments.empty? + segments.join("/") + anchor.to_s + end + end +end
app/services/syntax_highlighter.rb
+41
new file mode 100644 index 0000000..d5e7969 --- /dev/null +++ b/app/services/syntax_highlighter.rb @@ -0,0 +1,41 @@ +require "rouge" +require "cgi" + +# Server-side syntax highlighting for the blob view. Returns one HTML fragment +# per source line so each line can be numbered and individually anchored. +# +# The whole file is tokenized in a single pass (so stateful constructs such as +# heredocs and multi-line strings highlight correctly), then the token stream +# is split on newline boundaries. Unknown languages fall back to a plain-text +# lexer; any lexer error falls back to HTML-escaped plain lines. +class SyntaxHighlighter + def self.lines(content, filename:) + new(content, filename: filename).lines + end + + def initialize(content, filename:) + @content = content + @filename = filename + end + + def lines + lexer = Rouge::Lexer.guess(filename: @filename, source: @content) || Rouge::Lexers::PlainText + formatter = Rouge::Formatters::HTML.new + + rows = [ [] ] + lexer.lex(@content).each do |token, value| + value.split(/(\n)/, -1).each do |part| + if part == "\n" + rows << [] + elsif !part.empty? + rows.last << [ token, part ] + end + end + end + rows.pop if rows.last&.empty? + + rows.map { |tokens| formatter.format(tokens) } + rescue StandardError + @content.lines.map { |l| CGI.escapeHTML(l.chomp) } + end +end
app/views/blobs/show.html.erb
+48 -9
index 9bde560..112a3f1 100644 --- a/app/views/blobs/show.html.erb +++ b/app/views/blobs/show.html.erb @@ -47,8 +47,10 @@ </div> <div class="max-w-6xl mx-auto px-4 sm:px-6 py-6"> - <div class="card"> - <div class="px-4 py-2.5 border-b border-surface-600 flex items-center justify-between bg-surface-600"> + <% permalink_path = @commit_sha ? repository_blob_path(@owner.username, @repository.name, @commit_sha, @file_path) : nil %> + <div class="card" data-controller="clipboard line-selection" + <% if permalink_path %>data-line-selection-permalink-base-value="<%= permalink_path %>"<% end %>> + <div class="px-4 py-2.5 border-b border-surface-600 flex items-center justify-between gap-3 bg-surface-600"> <div class="flex items-center gap-3 text-xs text-gray-400"> <span class="font-mono badge-gray"><%= @extension.presence || "text" %></span> <% if @line_count %> @@ -57,12 +59,31 @@ <span><%= number_to_human_size(@file_size) %></span> </div> <div class="flex items-center gap-2"> - <%= link_to "Raw", repository_blob_raw_path(@owner.username, @repository.name, @branch, @file_path), - class: "btn-ghost text-xs py-1 px-2.5", target: "_blank" %> + <% if @markdown_html %> + <%= link_to "View source", repository_blob_path(@owner.username, @repository.name, @branch, @file_path, plain: 1), + class: "btn-ghost text-xs py-1 px-2.5" %> + <% elsif @plain && BlobsController::MARKDOWN_EXTENSIONS.include?(@extension) %> + <%= link_to "Rendered", repository_blob_path(@owner.username, @repository.name, @branch, @file_path), + class: "btn-ghost text-xs py-1 px-2.5" %> + <% end %> + <% if @highlighted_lines && permalink_path %> + <button type="button" data-action="line-selection#copyPermalink" + class="btn-ghost text-xs py-1 px-2.5">Copy permalink</button> + <button type="button" data-action="clipboard#copy" + class="btn-ghost text-xs py-1 px-2.5">Copy</button> + <% end %> + <%= link_to "Raw", @raw_path, class: "btn-ghost text-xs py-1 px-2.5", target: "_blank", rel: "noopener" %> </div> </div> - <% if @image_type %> + <% if @too_large %> + <div class="px-6 py-10 text-center text-sm text-gray-400"> + This file is <%= number_to_human_size(@file_size) %> — too large to display. + <div class="mt-4"> + <%= link_to "View raw", @raw_path, class: "btn-secondary text-xs py-1 px-3", target: "_blank", rel: "noopener" %> + </div> + </div> + <% elsif @image_type %> <div class="px-6 py-10 flex justify-center bg-surface-800"> <img src="<%= @image_data_uri %>" alt="<%= @filename %>" class="max-w-full h-auto rounded border border-surface-600" /> @@ -70,22 +91,40 @@ <% elsif @is_binary %> <div class="px-6 py-10 text-center text-sm text-gray-400"> Binary file — <%= number_to_human_size(@file_size) %> + <div class="mt-4"> + <%= link_to "View raw", @raw_path, class: "btn-secondary text-xs py-1 px-3", target: "_blank", rel: "noopener" %> + </div> </div> + <% elsif @markdown_html %> + <div class="px-6 py-6 prose-readme" data-controller="code-copy"><%= @markdown_html %></div> <% else %> + <% if @highlight_skipped %> + <div class="px-4 py-2 text-xs text-amber-300/80 bg-amber-900/10 border-b border-surface-600"> + Large file — syntax highlighting disabled. + </div> + <% end %> <div class="code-container rounded-none border-0 relative overflow-x-auto"> <table class="w-full"> <tbody class="highlight"> <% @highlighted_lines.each_with_index do |highlighted_line, i| %> - <tr class="hover:bg-brand-500/10"> - <td class="text-right text-gray-500 pr-4 pl-4 py-0 select-none w-10 text-xs border-r border-surface-600 align-top font-mono" id="L<%= i+1 %>"> - <a href="#L<%= i+1 %>" class="hover:text-brand-500"><%= i+1 %></a> + <% n = i + 1 %> + <tr id="L<%= n %>" data-line="<%= n %>" data-line-selection-target="row" class="line-row"> + <td class="line-number text-right text-gray-500 pr-4 pl-4 py-0 select-none w-10 text-xs border-r border-surface-600 align-top font-mono"> + <a href="#L<%= n %>" data-action="line-selection#select" + data-line-selection-line-param="<%= n %>" class="hover:text-brand-500"><%= n %></a> </td> - <td class="pl-4 pr-4 py-0 font-mono text-xs leading-5 whitespace-pre text-gray-200"><%= highlighted_line %></td> + <td data-clipboard-target="source" class="pl-4 pr-4 py-0 font-mono text-xs leading-5 whitespace-pre text-gray-200"><%= highlighted_line %></td> </tr> <% end %> </tbody> </table> </div> + <% if @truncated %> + <div class="px-4 py-3 text-xs text-gray-400 border-t border-surface-600 text-center"> + Showing first <%= number_with_delimiter(@highlighted_lines.length) %> of <%= number_with_delimiter(@line_count) %> lines. + <%= link_to "View raw", @raw_path, class: "text-brand-500 hover:underline", target: "_blank", rel: "noopener" %> + </div> + <% end %> <% end %> </div> </div>
app/views/repositories/show.html.erb
+24 -1
index 9ecf590..c7eee46 100644 --- a/app/views/repositories/show.html.erb +++ b/app/views/repositories/show.html.erb @@ -107,6 +107,29 @@ </svg> <%= number_with_delimiter(@commit_count) %> commits <% end %> + + <details class="clone-menu relative"> + <summary class="btn-secondary py-1 px-3 text-xs flex items-center gap-1.5 cursor-pointer list-none"> + <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"> + <path d="M2.75 2.5a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h10.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75ZM4 6.25A.75.75 0 0 1 4.75 5.5h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 6.25Zm.75 2.25a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5Z"/> + </svg> + Clone + </summary> + <div class="absolute right-0 mt-2 w-80 z-10 card p-3 space-y-3 text-left"> + <% [["HTTPS", @clone_https_url], ["SSH", @clone_ssh_url]].each do |label, url| %> + <div data-controller="clipboard" data-clipboard-text-value="<%= url %>"> + <div class="text-[11px] uppercase tracking-wide text-gray-500 mb-1"><%= label %></div> + <div class="flex items-center gap-2"> + <input type="text" readonly value="<%= url %>" + class="flex-1 bg-surface-800 border border-surface-600 rounded px-2 py-1 text-xs font-mono text-gray-200 focus:outline-none" + onclick="this.select()"> + <button type="button" data-action="clipboard#copy" + class="btn-ghost text-xs py-1 px-2.5 shrink-0">Copy</button> + </div> + </div> + <% end %> + </div> + </details> </div> </div> @@ -140,7 +163,7 @@ </svg> <span class="text-xs font-medium text-gray-400"><%= @readme_filename %></span> </div> - <div class="px-6 py-6 prose-readme"> + <div class="px-6 py-6 prose-readme" data-controller="code-copy"> <%= @readme_html %> </div> </div>
config/brakeman.ignore
+35
new file mode 100644 index 0000000..1472c83 --- /dev/null +++ b/config/brakeman.ignore @@ -0,0 +1,35 @@ +{ + "ignored_warnings": [ + { + "warning_type": "Command Injection", + "warning_code": 14, + "fingerprint": "1dbb9af6b074159eced6b9d2536bec26667c240f09e3a5bb49285b45775cca4b", + "check_name": "Execute", + "message": "Possible command injection", + "file": "app/services/git_repository_service.rb", + "line": 93, + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref/path is passed to git as a single literal argv entry and cannot inject shell commands." + }, + { + "warning_type": "Command Injection", + "warning_code": 14, + "fingerprint": "0a470c5a46cd5b8ac1b0487e651f761caeeb961ac9bc408a33d2ca89257085c3", + "check_name": "Execute", + "message": "Possible command injection", + "file": "app/services/git_repository_service.rb", + "line": 100, + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref/path is passed to git as a single literal argv entry and cannot inject shell commands." + }, + { + "warning_type": "Command Injection", + "warning_code": 14, + "fingerprint": "586ef55597b40deec327d690a446903cdfb89a65dfeb7962ad0f6f603d16f9fd", + "check_name": "Execute", + "message": "Possible command injection", + "file": "app/services/git_repository_service.rb", + "line": 107, + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref is passed to git as a single literal argv entry and cannot inject shell commands." + } + ], + "brakeman_version": "8.0.5" +}
spec/requests/blob_view_spec.rb
+78
new file mode 100644 index 0000000..8ba8522 --- /dev/null +++ b/spec/requests/blob_view_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" +require "tmpdir" +require "fileutils" + +# End-to-end checks for the rendered read experience: a README renders (not +# raw) on the repo landing page, source files get syntax highlighting + line +# numbers, .md blobs render, the permalink pins to a commit SHA, and malicious +# HTML in a README is neutralised. +RSpec.describe "Blob view", type: :request do + around do |example| + Dir.mktmpdir("blob-view-spec") do |tmp| + @repo_dir = File.join(tmp, "demo.git") + build_repo(@repo_dir, + "README.md" => "# Demo Project\n\nHello <script>alert('xss')</script> world\n\n- [x] done\n- [ ] todo\n", + "main.rb" => "def greet\n puts 'hi'\nend\n") + @sha = `git --git-dir #{@repo_dir} rev-parse main`.strip + example.run + end + end + + let(:user) { User.create!(smbcloud_id: 123_321, email: "blob-spec@example.com", username: "blobspec") } + let!(:repo) { user.repositories.create!(name: "demo", disk_path: @repo_dir, default_branch: "main") } + + it "renders the README as HTML on the repo landing page" do + get "/blobspec/demo" + expect(response).to have_http_status(:success) + expect(response.body).to include('<div class="px-6 py-6 prose-readme"') + expect(response.body).to match(%r{<h1[^>]*>.*Demo Project}m) + end + + it "neutralises malicious HTML in the README" do + get "/blobspec/demo" + # The payload is gone entirely; the only <script> tags left are the page + # layout's own (JSON-LD / importmap), never the README's. + expect(response.body).not_to include("alert('xss')") + rendered = response.body[/prose-readme.*?<\/div>/m] + expect(rendered).not_to include("<script") + end + + it "shows syntax highlighting and per-line anchors for a source file" do + get "/blobspec/demo/blob/main/main.rb" + expect(response).to have_http_status(:success) + expect(response.body).to include('id="L1"').and include('id="L2"') + expect(response.body).to include('class="highlight"') + end + + it "pins the copy-permalink base to the commit SHA, not the branch" do + get "/blobspec/demo/blob/main/main.rb" + expect(response.body).to include( + %(data-line-selection-permalink-base-value="/blobspec/demo/blob/#{@sha}/main.rb") + ) + end + + it "renders an .md blob as Markdown by default and as source with ?plain" do + get "/blobspec/demo/blob/main/README.md" + expect(response.body).to match(%r{<h1[^>]*>.*Demo Project}m) + + get "/blobspec/demo/blob/main/README.md?plain=1" + expect(response.body).to include('id="L1"') + end + + def build_repo(bare_path, files) + FileUtils.mkdir_p(bare_path) + system("git", "init", "--bare", bare_path, exception: true) + Dir.mktmpdir do |work| + system("git", "-C", work, "init", "-b", "main", exception: true) + system("git", "-C", work, "config", "user.email", "t@example.com", exception: true) + system("git", "-C", work, "config", "user.name", "Test", exception: true) + files.each { |name, content| File.write(File.join(work, name), content) } + system("git", "-C", work, "add", ".", exception: true) + system("git", "-C", work, "commit", "-m", "seed", exception: true) + system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true) + system("git", "-C", work, "push", "origin", "main", exception: true) + end + end +end
spec/requests/raw_blob_spec.rb
+75
new file mode 100644 index 0000000..c4e142e --- /dev/null +++ b/spec/requests/raw_blob_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "rails_helper" +require "tmpdir" +require "fileutils" +require "base64" + +# The /raw endpoint is what install scripts, badges, and CI hit, so it must +# return the exact bytes with a sensible content-type and the nosniff guard +# that stops the browser from running user content as active content on the +# app origin. +RSpec.describe "Raw blob", type: :request do + around do |example| + Dir.mktmpdir("raw-blob-spec") do |tmp| + @repo_dir = File.join(tmp, "demo.git") + build_repo(@repo_dir, + "hello.txt" => "plain bytes\n", + "page.html" => "<h1>hi</h1>\n", + "logo.png" => png_bytes) + example.run + end + end + + let(:user) { User.create!(smbcloud_id: 987_654, email: "raw-spec@example.com", username: "rawspec") } + let!(:repo) { user.repositories.create!(name: "demo", disk_path: @repo_dir, default_branch: "main") } + + it "returns exact bytes with text/plain and nosniff for a text file" do + get "/rawspec/demo/raw/main/hello.txt" + expect(response).to have_http_status(:success) + expect(response.body).to eq("plain bytes\n") + expect(response.media_type).to match(%r{text/plain}) + expect(response.headers["X-Content-Type-Options"]).to eq("nosniff") + end + + it "serves HTML files as text/plain so they cannot run as active content" do + get "/rawspec/demo/raw/main/page.html" + expect(response).to have_http_status(:success) + expect(response.media_type).to match(%r{text/plain}) + expect(response.headers["X-Content-Type-Options"]).to eq("nosniff") + end + + it "serves images with their real content-type" do + get "/rawspec/demo/raw/main/logo.png" + expect(response).to have_http_status(:success) + expect(response.media_type).to eq("image/png") + expect(response.headers["X-Content-Type-Options"]).to eq("nosniff") + end + + it "returns 404 for a missing file" do + get "/rawspec/demo/raw/main/does-not-exist.txt" + expect(response).to have_http_status(:not_found) + end + + def build_repo(bare_path, files) + FileUtils.mkdir_p(bare_path) + system("git", "init", "--bare", bare_path, exception: true) + Dir.mktmpdir do |work| + system("git", "-C", work, "init", "-b", "main", exception: true) + system("git", "-C", work, "config", "user.email", "t@example.com", exception: true) + system("git", "-C", work, "config", "user.name", "Test", exception: true) + files.each { |name, content| File.binwrite(File.join(work, name), content) } + system("git", "-C", work, "add", ".", exception: true) + system("git", "-C", work, "commit", "-m", "seed", exception: true) + system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true) + system("git", "-C", work, "push", "origin", "main", exception: true) + end + end + + # Smallest valid 1x1 PNG. + def png_bytes + Base64.decode64( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + ) + end +end
spec/services/markdown_renderer_spec.rb
+114
new file mode 100644 index 0000000..4ee97cd --- /dev/null +++ b/spec/services/markdown_renderer_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe MarkdownRenderer do + def render(md, context: nil) + described_class.render(md, context: context) + end + + describe "sanitization (the security boundary)" do + it "strips <script> tags and their contents" do + html = render("Hello\n\n<script>alert('xss')</script>") + expect(html).not_to include("<script") + expect(html).not_to include("alert('xss')") + end + + it "strips <iframe> elements" do + expect(render("<iframe src='https://evil.example'></iframe>")).not_to include("<iframe") + end + + it "strips inline event handlers" do + html = render("<img src='x' onerror='alert(1)'>") + expect(html).not_to include("onerror") + expect(html).not_to include("alert(1)") + end + + it "strips javascript: scheme links" do + expect(render("[click](javascript:alert(1))")).not_to include("javascript:") + end + + it "keeps safe http(s) links" do + expect(render("[siGit](https://sigit.si)")).to include('href="https://sigit.si"') + end + end + + describe "GitHub-Flavored Markdown" do + it "renders tables" do + html = render("| a | b |\n|---|---|\n| 1 | 2 |") + expect(html).to include("<table").and include("<th").and include("<td") + end + + it "renders task lists as disabled checkboxes" do + html = render("- [ ] todo\n- [x] done") + expect(html.scan(/<input[^>]*type="checkbox"/).length).to eq(2) + expect(html).to include("checked") + expect(html).to include("task-list-item") + end + + it "gives headings anchor ids and links" do + html = render("# Hello World") + expect(html).to include('id="hello-world"').and include('href="#hello-world"') + end + + it "makes duplicate heading ids unique" do + html = render("# Title\n\n# Title") + expect(html).to include('id="title"').and include('id="title-1"') + end + + it "syntax-highlights fenced code" do + html = render("```ruby\nputs 1\n```") + expect(html).to include('class="highlight code-block"').and include("<span") + end + + it "autolinks bare URLs" do + expect(render("see https://sigit.si for more")).to include('href="https://sigit.si"') + end + end + + describe "relative link/image resolution" do + let(:ctx) { MarkdownRenderer::Context.new(username: "sigit", repository: "demo", ref: "main", dir: dir) } + let(:dir) { "" } + + it "resolves a relative link to the blob view" do + expect(render("[docs](docs/guide.md)", context: ctx)) + .to include('href="/sigit/demo/blob/main/docs/guide.md"') + end + + it "resolves a relative image to the raw endpoint" do + expect(render("![logo](assets/logo.png)", context: ctx)) + .to include('src="/sigit/demo/raw/main/assets/logo.png"') + end + + context "from a nested directory" do + let(:dir) { "docs" } + + it "resolves against the current directory" do + expect(render("[sibling](other.md)", context: ctx)) + .to include('href="/sigit/demo/blob/main/docs/other.md"') + end + end + + context "with parent traversal" do + let(:dir) { "docs/api" } + + it "collapses .. against the current directory" do + expect(render("[up](../intro.md)", context: ctx)) + .to include('href="/sigit/demo/blob/main/docs/intro.md"') + end + end + + it "leaves absolute URLs untouched" do + expect(render("[x](https://example.com/a)", context: ctx)).to include('href="https://example.com/a"') + end + + it "leaves in-page anchors untouched" do + expect(render("[top](#intro)", context: ctx)).to include('href="#intro"') + end + end + + it "returns an empty string for blank input" do + expect(render("")).to eq("") + expect(render(nil)).to eq("") + end +end
spec/services/syntax_highlighter_spec.rb
+37
new file mode 100644 index 0000000..5428faa --- /dev/null +++ b/spec/services/syntax_highlighter_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SyntaxHighlighter do + it "returns one fragment per source line" do + expect(described_class.lines("def a\n 1\nend\n", filename: "a.rb").length).to eq(3) + end + + it "does not emit a trailing blank line when the file ends with a newline" do + expect(described_class.lines("one\ntwo\n", filename: "f.txt").length).to eq(2) + end + + it "counts the final line when there is no trailing newline" do + expect(described_class.lines("one\ntwo", filename: "f.txt").length).to eq(2) + end + + it "highlights a known language" do + expect(described_class.lines("puts 'hi'\n", filename: "x.rb").first).to include("<span") + end + + it "falls back to plain text for an unknown extension" do + lines = described_class.lines("just text\n", filename: "x.unknownext") + expect(lines.length).to eq(1) + expect(lines.first).to include("just text") + end + + it "escapes HTML in the content" do + line = described_class.lines("<script>\n", filename: "x.unknownext").first + expect(line).not_to include("<script>") + expect(line).to include("&lt;script&gt;") + end + + it "handles empty content" do + expect(described_class.lines("", filename: "x.txt")).to eq([]) + end +end