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 00796210f467cb21278206b70d1da18be6c39a8e
1 file changed +221
.agents/specs/rendered-read-view.md new
+221
@@ -0,0 +1,221 @@
1 +# Spec: Rendered README and web code-reading view
2 +
3 +Status: implemented on `feature/rendered-read-view`.
4 +Scope owner: `sigit-si` (Rails web app).
5 +
6 +This is a reverse-engineered specification of the repository read experience:
7 +rendered Markdown, the syntax-highlighted blob view, permalinks and line
8 +selection, the raw endpoint, and the rendering security boundary. It documents
9 +the contract the code fulfills so the behavior can be re-implemented or verified
10 +independently. Requirement IDs (RM-, BV-, PL-, RAW-, SEC-, PERF-) are referenced
11 +by the test map at the end.
12 +
13 +## 1. Goal and non-goals
14 +
15 +Goal: make the web repo-browsing experience render code and Markdown instead of
16 +serving them as plain text, with reading affordances comparable to a standard
17 +code host (rendered READMEs, highlighting, line numbers, permalinks, raw bytes).
18 +
19 +Non-goals (not built here): pull-request review UI, issues, blame, code search,
20 +diff view. This spec covers README rendering and the blob view only.
21 +
22 +## 2. Surfaces and routes
23 +
24 +Existing routes (unchanged by this work, listed for context):
25 +
26 +| Route | Controller action | Purpose |
27 +|-------|-------------------|---------|
28 +| `GET /:username/:repository` | `repositories#show` | repo landing page, renders README |
29 +| `GET /:username/:repository/blob/:branch/*path` | `blobs#show` | file view |
30 +| `GET /:username/:repository/raw/:branch/*path` | `blobs#raw` | exact bytes |
31 +
32 +`:branch` accepts any ref string, including a full commit SHA (used by
33 +permalinks). `*path` is the repository-relative file path.
34 +
35 +## 3. Components
36 +
37 +| Component | File | Responsibility |
38 +|-----------|------|----------------|
39 +| `MarkdownRenderer` | `app/services/markdown_renderer.rb` | GFM to sanitized HTML |
40 +| `SyntaxHighlighter` | `app/services/syntax_highlighter.rb` | per-line highlighted HTML |
41 +| `GitRepositoryService` (extended) | `app/services/git_repository_service.rb` | `blob_sha`, `blob_size`, `commit_sha` |
42 +| `BlobsController` | `app/controllers/blobs_controller.rb` | blob view, raw, large-file and binary handling, caching |
43 +| `RepositoriesController` | `app/controllers/repositories_controller.rb` | README render, clone URLs |
44 +| `line_selection_controller.js` | `app/javascript/controllers/` | line anchors, range select, SHA permalink |
45 +| `clipboard_controller.js` | `app/javascript/controllers/` | copy code and clone URLs |
46 +| `code_copy_controller.js` | `app/javascript/controllers/` | copy buttons on rendered Markdown code blocks |
47 +
48 +## 4. Markdown rendering (RM)
49 +
50 +`MarkdownRenderer.render(text, context: nil)` returns a sanitized HTML string.
51 +`context` is a `MarkdownRenderer::Context` carrying `username`, `repository`,
52 +`ref`, and `dir` (the directory of the file being rendered, "" for repo root).
53 +
54 +- RM-1: Blank or nil input returns an empty string.
55 +- RM-2: GitHub-Flavored Markdown is supported: tables, task lists, strikethrough,
56 + autolinks, fenced code, footnotes, headings.
57 +- RM-3: Fenced code blocks are syntax-highlighted server-side with Rouge and
58 + wrapped in `<pre class="highlight code-block">`. An unknown or absent language
59 + falls back to plain text without raising.
60 +- RM-4: Headings get a stable id slug and an in-page anchor link
61 + (`<a class="heading-anchor" href="#slug">`). Repeated heading text yields
62 + unique slugs (`name`, `name-1`, `name-2`).
63 +- RM-5: Task list items (`- [ ]`, `- [x]`) render as disabled checkbox inputs on
64 + `li.task-list-item`.
65 +- RM-6: When `context` is present, relative links resolve to the blob view and
66 + relative images resolve to the raw endpoint, both against the repo at `ref`:
67 + - link `docs/guide.md` from root to `/:user/:repo/blob/:ref/docs/guide.md`
68 + - image `assets/logo.png` from root to `/:user/:repo/raw/:ref/assets/logo.png`
69 +- RM-7: Relative paths resolve against `context.dir`, collapsing `.` and `..`
70 + without escaping the repository root. A traversal that would escape the root is
71 + left unresolved.
72 +- RM-8: Absolute URLs (any scheme), root-relative paths (`/...`), `mailto:`, and
73 + in-page anchors (`#...`) are passed through unchanged.
74 +
75 +README discovery on the repo landing page is unchanged
76 +(`GitRepositoryService.readme_content`): the first match of `README.md`,
77 +`README.txt`, `README`, `readme.md`. Its rendered HTML is cached (see PERF-2).
78 +
79 +## 5. Blob view (BV)
80 +
81 +`BlobsController#show` pins the response and view formats to HTML so a file
82 +extension in the path does not trigger Rails format negotiation, then classifies
83 +the blob.
84 +
85 +- BV-1: A missing blob (`blob_sha` or `blob_size` nil) renders the 404 page.
86 +- BV-2: A previewable image (`png jpg jpeg gif webp avif bmp ico svg`) at or
87 + below `MAX_LOAD_BYTES` is shown inline as a base64 `data:` URI. SVG is safe
88 + here because it loads through an `<img>` tag.
89 +- BV-3: A file larger than `MAX_LOAD_BYTES` (5 MB) is not read into memory. The
90 + view shows a size notice and a "View raw" link only (`@too_large`).
91 +- BV-4: A binary file (NUL byte in the first 8192 bytes, or invalid UTF-8) shows
92 + a "Binary file" notice with size and a "View raw" link.
93 +- BV-5: A Markdown blob (`md markdown mdown mkd`) at or below
94 + `MAX_MARKDOWN_BYTES` (512 KB) renders as Markdown by default. `?plain=1` forces
95 + the highlighted source view, and a toggle link switches between the two.
96 +- BV-6: Any other text file is shown with per-line Rouge highlighting, line
97 + numbers, and a per-line anchor target (`tr#L<n>`).
98 +- BV-7: A text file larger than `MAX_HIGHLIGHT_BYTES` (512 KB) is shown as
99 + escaped plain text without highlighting, with a notice (`@highlight_skipped`).
100 +- BV-8: A file with more than `MAX_DISPLAY_LINES` (5000) lines is truncated to
101 + the first 5000 displayed lines, with a notice and a "View raw" link
102 + (`@truncated`).
103 +- BV-9: `SyntaxHighlighter.lines(content, filename:)` returns one HTML fragment
104 + per source line. It tokenizes the whole file in one pass so multi-line
105 + constructs highlight correctly, does not emit a trailing blank line when the
106 + file ends in a newline, counts a final line with no trailing newline, and on
107 + any lexer error falls back to HTML-escaped plain lines.
108 +
109 +## 6. Permalinks and line selection (PL)
110 +
111 +Implemented in `line_selection_controller.js`, driven by data attributes on the
112 +blob table and line-number anchors.
113 +
114 +- PL-1: Clicking a line number sets the URL hash to `#L<n>` and highlights that
115 + line (`.line-selected`).
116 +- PL-2: Shift-clicking a second line selects the inclusive range and sets the
117 + hash to `#L<lo>-L<hi>`, regardless of click order.
118 +- PL-3: On load (and on `hashchange`), a `#L<n>` or `#L<lo>-L<hi>` hash
119 + highlights the matching lines and scrolls the first into view.
120 +- PL-4: "Copy permalink" copies an absolute URL built from
121 + `data-line-selection-permalink-base-value` plus the current line selection.
122 + The base path is `repository_blob_path(owner, repo, @commit_sha, file_path)`,
123 + where `@commit_sha = GitRepositoryService.commit_sha(disk_path, branch)`. The
124 + link is pinned to the commit the ref points at, so it keeps resolving to the
125 + same content after the branch moves.
126 +
127 +## 7. Raw endpoint (RAW)
128 +
129 +`BlobsController#raw` serves the file bytes for install scripts, badges, and CI.
130 +
131 +- RAW-1: Returns the exact, unmodified bytes of the blob.
132 +- RAW-2: Sets `X-Content-Type-Options: nosniff`.
133 +- RAW-3: Raster image extensions are served with their real image content type;
134 + every other file is served as `text/plain; charset=utf-8`. SVG is served as
135 + text/plain (not `image/svg+xml`) so it cannot execute as active content on
136 + direct navigation.
137 +- RAW-4: A missing file returns 404 with no body.
138 +
139 +## 8. Security (SEC)
140 +
141 +- SEC-1: All rendered Markdown HTML passes through an allow-list sanitizer
142 + (`Rails::HTML5::SafeListSanitizer`, falling back to HTML4) before reaching a
143 + view. Only the tags and attributes in `MarkdownRenderer::ALLOWED_TAGS` and
144 + `ALLOWED_ATTRIBUTES` survive.
145 +- SEC-2: `<script>`, `<style>`, `<noscript>`, `<template>`, `<svg>`, `<math>`,
146 + `<head>`, `<title>`, `<object>`, and `<embed>` blocks are pruned with their
147 + contents before sanitization, so disallowed code never survives even as inert
148 + text.
149 +- SEC-3: Inline event handlers (`on*`) and dangerous-scheme URLs (for example
150 + `javascript:`) are removed. Redcarpet runs with `safe_links_only`; the
151 + sanitizer is the authoritative gate.
152 +- SEC-4: Raw user content cannot run as active content on the app origin
153 + (RAW-2, RAW-3).
154 +- SEC-5: Result: malicious HTML or JS embedded in a README does not execute and
155 + its payload text does not appear in the rendered output.
156 +
157 +## 9. Performance (PERF)
158 +
159 +- PERF-1: Highlighting and Markdown rendering run server-side.
160 +- PERF-2: Rendered output is cached in `Rails.cache` keyed by the blob SHA
161 + (content-addressed, so the cache is shared across refs and never goes stale).
162 + Markdown cache keys also include `ref` and `dir` because relative-link
163 + resolution depends on them.
164 +- PERF-3: Blob size is read with `git cat-file -s` before the content is loaded,
165 + so the large-file thresholds (BV-3, BV-7, BV-8) avoid loading or tokenizing
166 + oversized files.
167 +
168 +## 10. Convenience controls
169 +
170 +- CV-1: The blob view has a "Copy" button that copies the file text (joined from
171 + the per-line code cells via `clipboard_controller.js`).
172 +- CV-2: Rendered Markdown code blocks get a hover "Copy" button injected by
173 + `code_copy_controller.js`.
174 +- CV-3: The repo page has a Clone control exposing HTTPS and SSH URLs, each with
175 + a copy button. URLs come from `request.base_url`/`request.host` so they match
176 + the serving host.
177 +
178 +## 11. Constants
179 +
180 +Defined on `BlobsController`:
181 +
182 +| Constant | Value | Requirement |
183 +|----------|-------|-------------|
184 +| `MAX_HIGHLIGHT_BYTES` | 512 KB | BV-7 |
185 +| `MAX_MARKDOWN_BYTES` | 512 KB | BV-5 |
186 +| `MAX_LOAD_BYTES` | 5 MB | BV-3 |
187 +| `MAX_DISPLAY_LINES` | 5000 | BV-8 |
188 +| `MARKDOWN_EXTENSIONS` | `md markdown mdown mkd` | BV-5 |
189 +| `PREVIEW_IMAGE_TYPES` | raster set plus `svg` | BV-2 |
190 +| `RAW_IMAGE_TYPES` | raster set, no `svg` | RAW-3 |
191 +
192 +## 12. Test map
193 +
194 +RSpec (`spec/`) is the project test framework. Tests assume the Rails
195 +environment boots and a Postgres test database is available
196 +(`bin/rails db:test:prepare`).
197 +
198 +| Requirement | Test |
199 +|-------------|------|
200 +| RM-1, RM-2, RM-3, RM-4, RM-5, RM-6, RM-7, RM-8 | `spec/services/markdown_renderer_spec.rb` |
201 +| BV-9 | `spec/services/syntax_highlighter_spec.rb` |
202 +| BV-5, BV-6, PL-4, SEC-5 | `spec/requests/blob_view_spec.rb` |
203 +| RAW-1, RAW-2, RAW-3, RAW-4 | `spec/requests/raw_blob_spec.rb` |
204 +| SEC-1, SEC-2, SEC-3 | `spec/services/markdown_renderer_spec.rb` (sanitization group) |
205 +
206 +Gaps not yet covered by automated tests (manual or future work): BV-3, BV-4,
207 +BV-7, BV-8 (the large-file and binary branches), PL-1 to PL-3 (client-side line
208 +selection), CV-1 to CV-3 (clipboard controllers).
209 +
210 +## 13. Verification
211 +
212 +```
213 +bundle exec rspec spec/services spec/requests # renderer, highlighter, raw, blob view
214 +bundle exec rails server # then open /:user/:repo with a README
215 +```
216 +
217 +Manual checks on a running server: a README renders as HTML with working code
218 +copy buttons; a source file shows highlighting and line numbers; `#L10`
219 +highlights and scrolls; click and shift-click select lines and update the URL;
220 +"Copy permalink" yields a SHA-pinned URL; `/raw/...` returns exact bytes with
221 +`X-Content-Type-Options: nosniff`.