docs(specs): reverse-engineered spec for the rendered read view

Add .agents/specs/ with a specification of the rendered README and blob view: markdown rendering, blob classification and large-file handling, permalinks and line selection, the raw endpoint, the sanitization security boundary, caching, and a requirement-to-test map. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 30, 2026 at 19:33 UTC f959e0c34b62618f3bb267faf21ccafe8cc61b42
1 file changed +221
.agents/specs/rendered-read-view.md
+221
new file mode 100644 index 0000000..fb8925b --- /dev/null +++ b/.agents/specs/rendered-read-view.md @@ -0,0 +1,221 @@ +# Spec: Rendered README and web code-reading view + +Status: implemented on `feature/rendered-read-view`. +Scope owner: `sigit-si` (Rails web app). + +This is a reverse-engineered specification of the repository read experience: +rendered Markdown, the syntax-highlighted blob view, permalinks and line +selection, the raw endpoint, and the rendering security boundary. It documents +the contract the code fulfills so the behavior can be re-implemented or verified +independently. Requirement IDs (RM-, BV-, PL-, RAW-, SEC-, PERF-) are referenced +by the test map at the end. + +## 1. Goal and non-goals + +Goal: make the web repo-browsing experience render code and Markdown instead of +serving them as plain text, with reading affordances comparable to a standard +code host (rendered READMEs, highlighting, line numbers, permalinks, raw bytes). + +Non-goals (not built here): pull-request review UI, issues, blame, code search, +diff view. This spec covers README rendering and the blob view only. + +## 2. Surfaces and routes + +Existing routes (unchanged by this work, listed for context): + +| Route | Controller action | Purpose | +|-------|-------------------|---------| +| `GET /:username/:repository` | `repositories#show` | repo landing page, renders README | +| `GET /:username/:repository/blob/:branch/*path` | `blobs#show` | file view | +| `GET /:username/:repository/raw/:branch/*path` | `blobs#raw` | exact bytes | + +`:branch` accepts any ref string, including a full commit SHA (used by +permalinks). `*path` is the repository-relative file path. + +## 3. Components + +| Component | File | Responsibility | +|-----------|------|----------------| +| `MarkdownRenderer` | `app/services/markdown_renderer.rb` | GFM to sanitized HTML | +| `SyntaxHighlighter` | `app/services/syntax_highlighter.rb` | per-line highlighted HTML | +| `GitRepositoryService` (extended) | `app/services/git_repository_service.rb` | `blob_sha`, `blob_size`, `commit_sha` | +| `BlobsController` | `app/controllers/blobs_controller.rb` | blob view, raw, large-file and binary handling, caching | +| `RepositoriesController` | `app/controllers/repositories_controller.rb` | README render, clone URLs | +| `line_selection_controller.js` | `app/javascript/controllers/` | line anchors, range select, SHA permalink | +| `clipboard_controller.js` | `app/javascript/controllers/` | copy code and clone URLs | +| `code_copy_controller.js` | `app/javascript/controllers/` | copy buttons on rendered Markdown code blocks | + +## 4. Markdown rendering (RM) + +`MarkdownRenderer.render(text, context: nil)` returns a sanitized HTML string. +`context` is a `MarkdownRenderer::Context` carrying `username`, `repository`, +`ref`, and `dir` (the directory of the file being rendered, "" for repo root). + +- RM-1: Blank or nil input returns an empty string. +- RM-2: GitHub-Flavored Markdown is supported: tables, task lists, strikethrough, + autolinks, fenced code, footnotes, headings. +- RM-3: Fenced code blocks are syntax-highlighted server-side with Rouge and + wrapped in `<pre class="highlight code-block">`. An unknown or absent language + falls back to plain text without raising. +- RM-4: Headings get a stable id slug and an in-page anchor link + (`<a class="heading-anchor" href="#slug">`). Repeated heading text yields + unique slugs (`name`, `name-1`, `name-2`). +- RM-5: Task list items (`- [ ]`, `- [x]`) render as disabled checkbox inputs on + `li.task-list-item`. +- RM-6: When `context` is present, relative links resolve to the blob view and + relative images resolve to the raw endpoint, both against the repo at `ref`: + - link `docs/guide.md` from root to `/:user/:repo/blob/:ref/docs/guide.md` + - image `assets/logo.png` from root to `/:user/:repo/raw/:ref/assets/logo.png` +- RM-7: Relative paths resolve against `context.dir`, collapsing `.` and `..` + without escaping the repository root. A traversal that would escape the root is + left unresolved. +- RM-8: Absolute URLs (any scheme), root-relative paths (`/...`), `mailto:`, and + in-page anchors (`#...`) are passed through unchanged. + +README discovery on the repo landing page is unchanged +(`GitRepositoryService.readme_content`): the first match of `README.md`, +`README.txt`, `README`, `readme.md`. Its rendered HTML is cached (see PERF-2). + +## 5. Blob view (BV) + +`BlobsController#show` pins the response and view formats to HTML so a file +extension in the path does not trigger Rails format negotiation, then classifies +the blob. + +- BV-1: A missing blob (`blob_sha` or `blob_size` nil) renders the 404 page. +- BV-2: A previewable image (`png jpg jpeg gif webp avif bmp ico svg`) at or + below `MAX_LOAD_BYTES` is shown inline as a base64 `data:` URI. SVG is safe + here because it loads through an `<img>` tag. +- BV-3: A file larger than `MAX_LOAD_BYTES` (5 MB) is not read into memory. The + view shows a size notice and a "View raw" link only (`@too_large`). +- BV-4: A binary file (NUL byte in the first 8192 bytes, or invalid UTF-8) shows + a "Binary file" notice with size and a "View raw" link. +- BV-5: A Markdown blob (`md markdown mdown mkd`) at or below + `MAX_MARKDOWN_BYTES` (512 KB) renders as Markdown by default. `?plain=1` forces + the highlighted source view, and a toggle link switches between the two. +- BV-6: Any other text file is shown with per-line Rouge highlighting, line + numbers, and a per-line anchor target (`tr#L<n>`). +- BV-7: A text file larger than `MAX_HIGHLIGHT_BYTES` (512 KB) is shown as + escaped plain text without highlighting, with a notice (`@highlight_skipped`). +- BV-8: A file with more than `MAX_DISPLAY_LINES` (5000) lines is truncated to + the first 5000 displayed lines, with a notice and a "View raw" link + (`@truncated`). +- BV-9: `SyntaxHighlighter.lines(content, filename:)` returns one HTML fragment + per source line. It tokenizes the whole file in one pass so multi-line + constructs highlight correctly, does not emit a trailing blank line when the + file ends in a newline, counts a final line with no trailing newline, and on + any lexer error falls back to HTML-escaped plain lines. + +## 6. Permalinks and line selection (PL) + +Implemented in `line_selection_controller.js`, driven by data attributes on the +blob table and line-number anchors. + +- PL-1: Clicking a line number sets the URL hash to `#L<n>` and highlights that + line (`.line-selected`). +- PL-2: Shift-clicking a second line selects the inclusive range and sets the + hash to `#L<lo>-L<hi>`, regardless of click order. +- PL-3: On load (and on `hashchange`), a `#L<n>` or `#L<lo>-L<hi>` hash + highlights the matching lines and scrolls the first into view. +- PL-4: "Copy permalink" copies an absolute URL built from + `data-line-selection-permalink-base-value` plus the current line selection. + The base path is `repository_blob_path(owner, repo, @commit_sha, file_path)`, + where `@commit_sha = GitRepositoryService.commit_sha(disk_path, branch)`. The + link is pinned to the commit the ref points at, so it keeps resolving to the + same content after the branch moves. + +## 7. Raw endpoint (RAW) + +`BlobsController#raw` serves the file bytes for install scripts, badges, and CI. + +- RAW-1: Returns the exact, unmodified bytes of the blob. +- RAW-2: Sets `X-Content-Type-Options: nosniff`. +- RAW-3: Raster image extensions are served with their real image content type; + every other file is served as `text/plain; charset=utf-8`. SVG is served as + text/plain (not `image/svg+xml`) so it cannot execute as active content on + direct navigation. +- RAW-4: A missing file returns 404 with no body. + +## 8. Security (SEC) + +- SEC-1: All rendered Markdown HTML passes through an allow-list sanitizer + (`Rails::HTML5::SafeListSanitizer`, falling back to HTML4) before reaching a + view. Only the tags and attributes in `MarkdownRenderer::ALLOWED_TAGS` and + `ALLOWED_ATTRIBUTES` survive. +- SEC-2: `<script>`, `<style>`, `<noscript>`, `<template>`, `<svg>`, `<math>`, + `<head>`, `<title>`, `<object>`, and `<embed>` blocks are pruned with their + contents before sanitization, so disallowed code never survives even as inert + text. +- SEC-3: Inline event handlers (`on*`) and dangerous-scheme URLs (for example + `javascript:`) are removed. Redcarpet runs with `safe_links_only`; the + sanitizer is the authoritative gate. +- SEC-4: Raw user content cannot run as active content on the app origin + (RAW-2, RAW-3). +- SEC-5: Result: malicious HTML or JS embedded in a README does not execute and + its payload text does not appear in the rendered output. + +## 9. Performance (PERF) + +- PERF-1: Highlighting and Markdown rendering run server-side. +- PERF-2: Rendered output is cached in `Rails.cache` keyed by the blob SHA + (content-addressed, so the cache is shared across refs and never goes stale). + Markdown cache keys also include `ref` and `dir` because relative-link + resolution depends on them. +- PERF-3: Blob size is read with `git cat-file -s` before the content is loaded, + so the large-file thresholds (BV-3, BV-7, BV-8) avoid loading or tokenizing + oversized files. + +## 10. Convenience controls + +- CV-1: The blob view has a "Copy" button that copies the file text (joined from + the per-line code cells via `clipboard_controller.js`). +- CV-2: Rendered Markdown code blocks get a hover "Copy" button injected by + `code_copy_controller.js`. +- CV-3: The repo page has a Clone control exposing HTTPS and SSH URLs, each with + a copy button. URLs come from `request.base_url`/`request.host` so they match + the serving host. + +## 11. Constants + +Defined on `BlobsController`: + +| Constant | Value | Requirement | +|----------|-------|-------------| +| `MAX_HIGHLIGHT_BYTES` | 512 KB | BV-7 | +| `MAX_MARKDOWN_BYTES` | 512 KB | BV-5 | +| `MAX_LOAD_BYTES` | 5 MB | BV-3 | +| `MAX_DISPLAY_LINES` | 5000 | BV-8 | +| `MARKDOWN_EXTENSIONS` | `md markdown mdown mkd` | BV-5 | +| `PREVIEW_IMAGE_TYPES` | raster set plus `svg` | BV-2 | +| `RAW_IMAGE_TYPES` | raster set, no `svg` | RAW-3 | + +## 12. Test map + +RSpec (`spec/`) is the project test framework. Tests assume the Rails +environment boots and a Postgres test database is available +(`bin/rails db:test:prepare`). + +| Requirement | Test | +|-------------|------| +| RM-1, RM-2, RM-3, RM-4, RM-5, RM-6, RM-7, RM-8 | `spec/services/markdown_renderer_spec.rb` | +| BV-9 | `spec/services/syntax_highlighter_spec.rb` | +| BV-5, BV-6, PL-4, SEC-5 | `spec/requests/blob_view_spec.rb` | +| RAW-1, RAW-2, RAW-3, RAW-4 | `spec/requests/raw_blob_spec.rb` | +| SEC-1, SEC-2, SEC-3 | `spec/services/markdown_renderer_spec.rb` (sanitization group) | + +Gaps not yet covered by automated tests (manual or future work): BV-3, BV-4, +BV-7, BV-8 (the large-file and binary branches), PL-1 to PL-3 (client-side line +selection), CV-1 to CV-3 (clipboard controllers). + +## 13. Verification + +``` +bundle exec rspec spec/services spec/requests # renderer, highlighter, raw, blob view +bundle exec rails server # then open /:user/:repo with a README +``` + +Manual checks on a running server: a README renders as HTML with working code +copy buttons; a source file shows highlighting and line numbers; `#L10` +highlights and scrolls; click and shift-click select lines and update the URL; +"Copy permalink" yields a SHA-pinned URL; `/raw/...` returns exact bytes with +`X-Content-Type-Options: nosniff`.