feat render README files as documentation

Render Markdown and Org README files semantically instead of as line-numbered source listings: - Headings use h1–h6 with clickable local fragment links - Prose uses a sans-serif documentation layout - Markdown and Org lists, paragraphs, and source blocks render as documents - Org TITLE, AUTHOR, DATE, EMAIL, and LANGUAGE directives become metadata - README blobs render as documents while other blobs remain code listings - Repository README lookup now preserves the filename to choose Org vs Markdown - Extract README styles into static/readme.css Add renderer tests for Markdown, Org metadata/source blocks, fragment links, and README filename detection.

Commit
68d4711929479b202f4572340e6ea52931804448
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
.kiro/steering/web-design.md
index f09bab24..e752c1da 100644..100644
@@ -42,6 +42,14 @@
42 42 - The colour theme lives in `lib/static/syntax-theme.css` (currently Gruvbox Dark Hard Contrast) and is decoupled from structural styles.
43 43 - CSS selectors use `[class*="-keyword"]` attribute matching for language-agnostic theming.
44 44
45 Added: ## README Documentation
46 Added:
47 Added: - README files in Markdown and Org mode SHALL render as semantic documentation, not as line-numbered source listings.
48 Added: - README headings SHALL be native `h1`–`h6` elements with stable fragment IDs; the heading text links to its own `#fragment` URL.
49 Added: - README prose SHALL use the sans-serif documentation style in `lib/static/readme.css`; source-block syntax colours remain in `syntax-theme.css`.
50 Added: - Org `#+TITLE`, `#+AUTHOR`, `#+DATE`, and related metadata SHALL render as styled document metadata rather than raw directives.
51 Added: - Other README formats currently use the Markdown-compatible fallback. Add parser support deliberately before claiming native support for another format.
52 Added:
45 53 ## Pages
46 54
47 55 - The repository navigation SHALL expose only: Summary, Commits, Files.
lib/handlers.ml
index fa6eeaa7..3f76152e 100644..100644
@@ -261,7 +261,6 @@
261 261 blob.content)
262 262 | `Tree _ ->
263 263 error_response (Resolvers.Bad_request "object is a tree, not a blob")
264 Removed:
265 264 end
266 265
267 266 let project_dir config subdir =
lib/highlight.ml
index d67a7bb7..3074316c 100644..100644
@@ -1,19 +1,19 @@
1 1 (** Server-side syntax highlighting engine.
2 2
3 3 Tokenizes source code using TextMate grammars (via hilite) and produces
4 Removed: {!Dream_html.node} spans ready for embedding in the page. Falls back
4 Added: {!Dream_html.node} spans ready for embedding in the page. Falls back
5 5 gracefully to plain text when no grammar is available for the requested
6 6 language. *)
7 7
8 8 open Dream_html
9 9
10 Removed: (** A single highlighted line: a list of HTML nodes (spans with classes). *)
11 10 type line = node list
11 Added: (** A single highlighted line: a list of HTML nodes (spans with classes). *)
12 12
13 13 (** Highlight source code for the given language.
14 14
15 15 Returns a list of lines, each line being a list of [<span>] nodes with
16 Removed: appropriate CSS classes. If [lang] is [None] or the language is not
16 Added: appropriate CSS classes. If [lang] is [None] or the language is not
17 17 supported, returns plain-text lines (no spans, just escaped text).
18 18
19 19 The CSS classes follow hilite's convention:
@@ -33,8 +33,8 @@
33 33 | Some scope_name -> (
34 34 let tm = Lazy.force Highlight_grammars.registry in
35 35 match
36 Removed: Hilite.src_code_to_pairs ~escape:true ~lookup_method:`Scope_name
37 Removed: ~tm ~lang:scope_name source
36 Added: Hilite.src_code_to_pairs ~escape:true ~lookup_method:`Scope_name ~tm
37 Added: ~lang:scope_name source
38 38 with
39 39 | Error _ -> plain_lines ()
40 40 | Ok pairs ->
lib/highlight_grammars.ml
index 8b8ed1d3..90431fc1 100644..100644
@@ -1,7 +1,7 @@
1 1 (** Registry of bundled TextMate grammars for syntax highlighting.
2 2
3 3 Loads grammars embedded at build time via [ocaml-crunch] and registers them
4 Removed: with a shared {!TmLanguage.t} instance. Language lookup is by the canonical
4 Added: with a shared {!TmLanguage.t} instance. Language lookup is by the canonical
5 5 name string produced by {!Syntax.detect}. *)
6 6
7 7 (** The shared grammar registry, lazily initialised. *)
@@ -17,8 +17,9 @@
17 17 let json = Yojson.Basic.from_string data in
18 18 let grammar = TmLanguage.of_yojson_exn json in
19 19 TmLanguage.add_grammar t grammar
20 Removed: with _ -> (* Silently skip malformed grammars *)
21 Removed: ())
20 Added: with _ ->
21 Added: (* Silently skip malformed grammars *)
22 Added: ())
22 23 in
23 24 (* Register every grammar we ship. *)
24 25 List.iter
@@ -77,7 +78,7 @@
77 78 t)
78 79
79 80 (** Map from the canonical language name (as returned by {!Syntax.detect}) to
80 Removed: the scopeName used by the grammar file. This bridges the naming gap.
81 Added: the scopeName used by the grammar file. This bridges the naming gap.
81 82
82 83 Only languages for which a grammar JSON is actually bundled appear here.
83 84 Languages detected by {!Syntax} but without a shipped grammar (e.g. elixir,
lib/readme.ml
index 00000000..dd6c384e 000000..100644
@@ -0,0 +1,295 @@
1 Added: (** Semantic rendering for README documents.
2 Added:
3 Added: README files are prose, not source listings: Markdown and Org are rendered
4 Added: as headings, paragraphs, lists, metadata, and unnumbered code blocks. The
5 Added: renderer deliberately keeps its supported surface compact and safe; every
6 Added: source fragment is emitted through {!Ui}, so repository content is escaped.
7 Added: *)
8 Added:
9 Added: type format = Markdown | Org
10 Added:
11 Added: type block =
12 Added: | Heading of int * string
13 Added: | Paragraph of string
14 Added: | List of string list
15 Added: | Code of string option * string
16 Added:
17 Added: let first_word text =
18 Added: match
19 Added: String.split_on_char ' ' text |> List.filter (fun word -> word <> "")
20 Added: with
21 Added: | word :: _ -> Some word
22 Added: | [] -> None
23 Added:
24 Added: let is_readme_filename filename =
25 Added: Filename.basename filename |> String.lowercase_ascii
26 Added: |> String.starts_with ~prefix:"readme"
27 Added:
28 Added: let format_of_filename filename =
29 Added: match Filename.extension filename |> String.lowercase_ascii with
30 Added: | ".org" -> Org
31 Added: | _ -> Markdown
32 Added:
33 Added: let trim_end_hashes text =
34 Added: let text = String.trim text in
35 Added: let rec last_non_hash index =
36 Added: if index < 0 || text.[index] <> '#' then index else last_non_hash (index - 1)
37 Added: in
38 Added: let last = last_non_hash (String.length text - 1) in
39 Added: String.sub text 0 (last + 1) |> String.trim
40 Added:
41 Added: let markdown_heading line =
42 Added: let length = String.length line in
43 Added: let rec count_hashes index =
44 Added: if index < length && line.[index] = '#' then count_hashes (index + 1)
45 Added: else index
46 Added: in
47 Added: let level = count_hashes 0 in
48 Added: if level = 0 || level > 6 || level >= length || line.[level] <> ' ' then None
49 Added: else
50 Added: Some
51 Added: ( level,
52 Added: String.sub line (level + 1) (length - level - 1) |> trim_end_hashes )
53 Added:
54 Added: let org_heading line =
55 Added: let length = String.length line in
56 Added: let rec count_stars index =
57 Added: if index < length && line.[index] = '*' then count_stars (index + 1)
58 Added: else index
59 Added: in
60 Added: let level = count_stars 0 in
61 Added: if level = 0 || level >= length || line.[level] <> ' ' then None
62 Added: else
63 Added: Some
64 Added: ( min 6 level,
65 Added: String.sub line (level + 1) (length - level - 1) |> String.trim )
66 Added:
67 Added: let heading_of_line format =
68 Added: match format with Markdown -> markdown_heading | Org -> org_heading
69 Added:
70 Added: let list_item line =
71 Added: let length = String.length line in
72 Added: if length >= 2 && List.mem line.[0] [ '-'; '+'; '*' ] && line.[1] = ' ' then
73 Added: Some (String.sub line 2 (length - 2) |> String.trim)
74 Added: else None
75 Added:
76 Added: let markdown_fence line =
77 Added: let line = String.trim line in
78 Added: if String.length line < 3 then None
79 Added: else
80 Added: let marker = String.sub line 0 3 in
81 Added: if marker <> "```" && marker <> "~~~" then None
82 Added: else
83 Added: let language =
84 Added: String.sub line 3 (String.length line - 3) |> String.trim |> first_word
85 Added: in
86 Added: Some (marker, language)
87 Added:
88 Added: let org_src_begin line =
89 Added: let prefix = "#+begin_src" in
90 Added: let lower = String.lowercase_ascii (String.trim line) in
91 Added: if not (String.starts_with ~prefix lower) then None
92 Added: else
93 Added: let language =
94 Added: String.sub lower (String.length prefix)
95 Added: (String.length lower - String.length prefix)
96 Added: |> String.trim |> first_word
97 Added: in
98 Added: Some language
99 Added:
100 Added: let is_org_src_end line =
101 Added: String.trim line |> String.lowercase_ascii
102 Added: |> String.starts_with ~prefix:"#+end_src"
103 Added:
104 Added: let rec take_until close collected = function
105 Added: | [] -> (List.rev collected, [])
106 Added: | line :: rest when close line -> (List.rev collected, rest)
107 Added: | line :: rest -> take_until close (line :: collected) rest
108 Added:
109 Added: let is_code_opener format line =
110 Added: match format with
111 Added: | Markdown -> Option.is_some (markdown_fence line)
112 Added: | Org -> Option.is_some (org_src_begin line)
113 Added:
114 Added: let is_boundary format line =
115 Added: String.trim line = ""
116 Added: || Option.is_some (heading_of_line format line)
117 Added: || Option.is_some (list_item line)
118 Added: || is_code_opener format line
119 Added:
120 Added: let parse_blocks format lines =
121 Added: let rec take_list collected = function
122 Added: | line :: rest -> (
123 Added: match list_item line with
124 Added: | Some item -> take_list (item :: collected) rest
125 Added: | None -> (List.rev collected, line :: rest))
126 Added: | [] -> (List.rev collected, [])
127 Added: in
128 Added: let rec take_paragraph collected = function
129 Added: | line :: _ as rest when is_boundary format line ->
130 Added: (List.rev collected, rest)
131 Added: | line :: rest -> take_paragraph (String.trim line :: collected) rest
132 Added: | [] -> (List.rev collected, [])
133 Added: in
134 Added: let rec loop blocks = function
135 Added: | [] -> List.rev blocks
136 Added: | line :: rest when String.trim line = "" -> loop blocks rest
137 Added: | line :: rest -> (
138 Added: match heading_of_line format line with
139 Added: | Some (level, text) -> loop (Heading (level, text) :: blocks) rest
140 Added: | None -> (
141 Added: match format with
142 Added: | Markdown -> (
143 Added: match markdown_fence line with
144 Added: | Some (marker, language) ->
145 Added: let lines, rest =
146 Added: take_until
147 Added: (fun candidate ->
148 Added: String.starts_with ~prefix:marker
149 Added: (String.trim candidate))
150 Added: [] rest
151 Added: in
152 Added: loop
153 Added: (Code (language, String.concat "\n" lines) :: blocks)
154 Added: rest
155 Added: | None -> (
156 Added: match list_item line with
157 Added: | Some _ ->
158 Added: let items, rest = take_list [] (line :: rest) in
159 Added: loop (List items :: blocks) rest
160 Added: | None ->
161 Added: let paragraph, rest =
162 Added: take_paragraph [] (line :: rest)
163 Added: in
164 Added: loop
165 Added: (Paragraph (String.concat " " paragraph) :: blocks)
166 Added: rest))
167 Added: | Org -> (
168 Added: match org_src_begin line with
169 Added: | Some language ->
170 Added: let lines, rest = take_until is_org_src_end [] rest in
171 Added: loop
172 Added: (Code (language, String.concat "\n" lines) :: blocks)
173 Added: rest
174 Added: | None -> (
175 Added: match list_item line with
176 Added: | Some _ ->
177 Added: let items, rest = take_list [] (line :: rest) in
178 Added: loop (List items :: blocks) rest
179 Added: | None ->
180 Added: let paragraph, rest =
181 Added: take_paragraph [] (line :: rest)
182 Added: in
183 Added: loop
184 Added: (Paragraph (String.concat " " paragraph) :: blocks)
185 Added: rest))))
186 Added: in
187 Added: loop [] lines
188 Added:
189 Added: let org_metadata_line line =
190 Added: let prefix = "#+" in
191 Added: let line = String.trim line in
192 Added: if not (String.starts_with ~prefix line) then None
193 Added: else
194 Added: match String.index_opt line ':' with
195 Added: | None -> None
196 Added: | Some colon ->
197 Added: let key = String.sub line 2 (colon - 2) |> String.lowercase_ascii in
198 Added: let value =
199 Added: String.sub line (colon + 1) (String.length line - colon - 1)
200 Added: |> String.trim
201 Added: in
202 Added: if List.mem key [ "title"; "author"; "date"; "email"; "language" ] then
203 Added: Some (key, value)
204 Added: else None
205 Added:
206 Added: let split_org_metadata lines =
207 Added: List.fold_left
208 Added: (fun (metadata, body) line ->
209 Added: match org_metadata_line line with
210 Added: | None -> (metadata, line :: body)
211 Added: | Some entry -> (entry :: metadata, body))
212 Added: ([], []) lines
213 Added: |> fun (metadata, body) -> (List.rev metadata, List.rev body)
214 Added:
215 Added: let new_anchor () =
216 Added: let seen = Hashtbl.create 16 in
217 Added: fun text ->
218 Added: let base =
219 Added: let buffer = Buffer.create (String.length text) in
220 Added: let pending_separator = ref false in
221 Added: String.iter
222 Added: (fun character ->
223 Added: if
224 Added: (character >= 'a' && character <= 'z')
225 Added: || (character >= 'A' && character <= 'Z')
226 Added: || (character >= '0' && character <= '9')
227 Added: then (
228 Added: if !pending_separator && Buffer.length buffer > 0 then
229 Added: Buffer.add_char buffer '-';
230 Added: pending_separator := false;
231 Added: Buffer.add_char buffer (Char.lowercase_ascii character))
232 Added: else pending_separator := true)
233 Added: text;
234 Added: if Buffer.length buffer = 0 then "section" else Buffer.contents buffer
235 Added: in
236 Added: let count = Option.value (Hashtbl.find_opt seen base) ~default:0 + 1 in
237 Added: Hashtbl.replace seen base count;
238 Added: if count = 1 then base else Printf.sprintf "%s-%d" base count
239 Added:
240 Added: let heading anchor level text =
241 Added: let id = anchor text in
242 Added: Ui.heading ~id ~level ~class_:"readme-heading"
243 Added: [ Ui.text_link ~class_:"readme-heading-link" ~href:("#" ^ id) text ]
244 Added:
245 Added: let code_block language source =
246 Added: let nodes =
247 Added: match language with
248 Added: | None | Some "" -> [ Ui.text source ]
249 Added: | Some language ->
250 Added: Highlight.highlight ~lang:(Some language) source |> List.concat
251 Added: in
252 Added: Ui.code_block ~class_:"readme-code-block" nodes
253 Added:
254 Added: let render_block anchor = function
255 Added: | Heading (level, text) -> heading anchor level text
256 Added: | Paragraph text -> Ui.paragraph ~class_:"readme-paragraph" [ Ui.text text ]
257 Added: | List items ->
258 Added: Ui.items ~class_:"readme-list"
259 Added: (List.map (fun item -> Ui.item [ Ui.text item ]) items)
260 Added: | Code (language, source) -> code_block language source
261 Added:
262 Added: let render ~filename content =
263 Added: let format = format_of_filename filename in
264 Added: let lines = String.split_on_char '\n' content in
265 Added: let metadata, lines =
266 Added: match format with
267 Added: | Markdown -> ([], lines)
268 Added: | Org -> split_org_metadata lines
269 Added: in
270 Added: let anchor = new_anchor () in
271 Added: let title, metadata =
272 Added: match List.find_opt (fun (key, _) -> key = "title") metadata with
273 Added: | None -> (None, metadata)
274 Added: | Some (_, title) ->
275 Added: ( Some (heading anchor 1 title),
276 Added: List.filter (fun (key, _) -> key <> "title") metadata )
277 Added: in
278 Added: let metadata =
279 Added: match metadata with
280 Added: | [] -> Ui.nothing
281 Added: | metadata ->
282 Added: Ui.definitions ~class_:"readme-org-metadata"
283 Added: (List.map
284 Added: (fun (key, value) ->
285 Added: (String.capitalize_ascii key, [ Ui.text value ]))
286 Added: metadata)
287 Added: in
288 Added: let class_ =
289 Added: match format with
290 Added: | Markdown -> "readme-document readme-markdown"
291 Added: | Org -> "readme-document readme-org"
292 Added: in
293 Added: Ui.region ~class_
294 Added: (Option.to_list title @ [ metadata ]
295 Added: @ List.map (render_block anchor) (parse_blocks format lines))
lib/resolvers.ml
index cb2b5965..dbf31324 100644..100644
@@ -662,17 +662,27 @@
662 662 | Git.Value.Blob blob -> Lwt_result.return (`Blob (Blob.to_t blob))
663 663 | _ -> Lwt_result.fail (Not_found ("no tree or blob matches id " ^ id))
664 664
665 Added: module Readme = struct
666 Added: type t = { name : string; content : string }
667 Added: end
668 Added:
665 669 module Repo = struct
666 670 let readme repository =
667 671 let* tree = Tree.head repository in
668 672 match List.find_opt Entry.is_readme tree.entries with
669 673 | None -> Lwt_result.return None
670 Removed: | Some readme -> (
671 Removed: let* hash = hash_of_hex readme.hash in
674 Added: | Some entry -> (
675 Added: let* hash = hash_of_hex entry.hash in
672 676 Lwt_result.bind (read_value repository hash) @@ function
673 Removed: | Git.Value.Blob blob -> Lwt_result.return (Some (Blob.to_t blob))
674 Removed: | _ -> Lwt_result.fail (Internal ("could not read file " ^ readme.name))
675 Removed: )
677 Added: | Git.Value.Blob blob ->
678 Added: Lwt_result.return
679 Added: (Some
680 Added: Readme.
681 Added: {
682 Added: name = entry.name;
683 Added: content = Store.Value.Blob.to_string blob;
684 Added: })
685 Added: | _ -> Lwt_result.fail (Internal ("could not read file " ^ entry.name)))
676 686 end
677 687
678 688 let read_root_readme config =
lib/resolvers.mli
index 5cde55ca..c26ab138 100644..100644
@@ -131,8 +131,12 @@
131 131
132 132 (** {1 Repository helpers} *)
133 133
134 Added: module Readme : sig
135 Added: type t = { name : string; content : string }
136 Added: end
137 Added:
134 138 module Repo : sig
135 Removed: val readme : repository -> (Blob.t option, error) Lwt_result.t
139 Added: val readme : repository -> (Readme.t option, error) Lwt_result.t
136 140 end
137 141
138 142 (** {1 Root helpers} *)
lib/routes.ml
index 35855e3d..28923a5b 100644..100644
@@ -43,15 +43,7 @@
43 43 | File_detail of string
44 44 | Raw of string
45 45
46 Removed: let known_actions =
47 Removed: [
48 Removed: "summary";
49 Removed: "commits";
50 Removed: "commit";
51 Removed: "files";
52 Removed: "file";
53 Removed: "raw";
54 Removed: ]
46 Added: let known_actions = [ "summary"; "commits"; "commit"; "files"; "file"; "raw" ]
55 47
56 48 let dispatch path =
57 49 (* path is the full URL path without leading slash, e.g. "sub/repo/commits/" *)
lib/static/readme.css
index 00000000..85553f11 000000..100644
@@ -0,0 +1,127 @@
1 Added: /* ==========================================================================
2 Added: README documentation
3 Added:
4 Added: Semantic Markdown and Org README presentation. Source-code colours remain in
5 Added: syntax-theme.css; this file owns prose layout and Org metadata styling.
6 Added: ========================================================================== */
7 Added:
8 Added: .readme-document {
9 Added: max-width: 78ch;
10 Added: margin: 1.5rem auto;
11 Added: color: var(--color-text);
12 Added: font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
13 Added: font-size: 1rem;
14 Added: line-height: 1.65;
15 Added: }
16 Added:
17 Added: .readme-document .readme-heading {
18 Added: margin: 2.1rem 0 0.75rem;
19 Added: color: var(--color-text);
20 Added: font-family: inherit;
21 Added: line-height: 1.25;
22 Added: scroll-margin-top: calc(var(--nav-height) + var(--toolbar-height) + 1rem);
23 Added: }
24 Added:
25 Added: .readme-document > .readme-heading:first-child {
26 Added: margin-top: 0;
27 Added: }
28 Added:
29 Added: .readme-document h1.readme-heading {
30 Added: font-size: 2rem;
31 Added: border-bottom: 1px solid var(--color-border-subtle);
32 Added: padding-bottom: 0.4rem;
33 Added: }
34 Added:
35 Added: .readme-document h2.readme-heading {
36 Added: font-size: 1.55rem;
37 Added: }
38 Added:
39 Added: .readme-document h3.readme-heading {
40 Added: font-size: 1.25rem;
41 Added: }
42 Added:
43 Added: .readme-document h4.readme-heading,
44 Added: .readme-document h5.readme-heading,
45 Added: .readme-document h6.readme-heading {
46 Added: font-size: 1rem;
47 Added: }
48 Added:
49 Added: .readme-heading-link {
50 Added: color: inherit;
51 Added: text-decoration: none;
52 Added: }
53 Added:
54 Added: .readme-heading-link:hover,
55 Added: .readme-heading-link:focus-visible {
56 Added: color: var(--color-link);
57 Added: text-decoration: underline;
58 Added: text-underline-offset: 0.15em;
59 Added: }
60 Added:
61 Added: .readme-paragraph {
62 Added: margin: 0.75rem 0;
63 Added: }
64 Added:
65 Added: .readme-list {
66 Added: margin: 0.75rem 0;
67 Added: padding-left: 1.5rem;
68 Added: }
69 Added:
70 Added: .readme-list li + li {
71 Added: margin-top: 0.3rem;
72 Added: }
73 Added:
74 Added: .readme-code-block {
75 Added: margin: 1rem 0;
76 Added: padding: 0.9rem 1rem;
77 Added: overflow-x: auto;
78 Added: border: 1px solid var(--color-border-subtle);
79 Added: border-radius: 0.25rem;
80 Added: background-color: #1d2021;
81 Added: color: #ebdbb2;
82 Added: font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
83 Added: font-size: 0.9rem;
84 Added: line-height: 1.5;
85 Added: tab-size: 4;
86 Added: }
87 Added:
88 Added: .readme-code-block code {
89 Added: font-family: inherit;
90 Added: }
91 Added:
92 Added: /* Org mode metadata directives: #+TITLE, #+AUTHOR, #+DATE, and friends. */
93 Added: .readme-org-metadata {
94 Added: display: grid;
95 Added: grid-template-columns: max-content 1fr;
96 Added: gap: 0.25rem 0.75rem;
97 Added: margin: 0 0 1.5rem;
98 Added: padding: 0.75rem 1rem;
99 Added: border-left: 3px solid #83a598;
100 Added: background-color: #282828;
101 Added: color: var(--color-muted);
102 Added: font-size: 0.92rem;
103 Added: }
104 Added:
105 Added: .readme-org-metadata dt {
106 Added: color: #fabd2f;
107 Added: font-weight: 600;
108 Added: }
109 Added:
110 Added: .readme-org-metadata dd {
111 Added: margin: 0;
112 Added: }
113 Added:
114 Added: @media (max-width: 480px) {
115 Added: .readme-document {
116 Added: margin: 1rem 0;
117 Added: font-size: 0.98rem;
118 Added: }
119 Added:
120 Added: .readme-document h1.readme-heading {
121 Added: font-size: 1.65rem;
122 Added: }
123 Added:
124 Added: .readme-document h2.readme-heading {
125 Added: font-size: 1.35rem;
126 Added: }
127 Added: }
lib/static/styles.css
index 4dc28fb0..f75e66bc 100644..100644
@@ -950,28 +950,6 @@
950 950 text-decoration: underline;
951 951 }
952 952
953 Removed: /* Inline README display at bottom of directory listings */
954 Removed:
955 Removed: .readme-inline {
956 Removed: margin-top: 1.5em;
957 Removed: border: 1px solid var(--color-border-subtle);
958 Removed: border-radius: 0.25rem;
959 Removed: overflow-x: auto;
960 Removed: }
961 Removed:
962 Removed: .readme-inline h3 {
963 Removed: margin: 0;
964 Removed: padding: 0.6em 1em;
965 Removed: background-color: #242424;
966 Removed: font-size: 0.9em;
967 Removed: color: var(--color-muted);
968 Removed: border-bottom: 1px solid var(--color-border-subtle);
969 Removed: }
970 Removed:
971 Removed: .readme-inline .blob {
972 Removed: padding: 0.5em 0;
973 Removed: }
974 Removed:
975 953 .diff-file {
976 954 margin: 1.5em 0;
977 955 border: 1px solid var(--color-border-subtle);
lib/syntax.ml
index e5ff22a4..aa4420c8 100644..100644
@@ -16,56 +16,56 @@
16 16 | "dockerfile" -> Some "dockerfile"
17 17 | "dune" | "dune-project" | "dune-workspace" -> Some "dune"
18 18 | "jenkinsfile" -> Some "groovy"
19 Removed: | _ ->
20 Removed: match Filename.extension name |> String.lowercase_ascii with
21 Removed: | ".ml" | ".mli" -> Some "ocaml"
22 Removed: | ".c" | ".h" -> Some "c"
23 Removed: | ".clj" | ".cljs" | ".cljc" | ".edn" -> Some "clojure"
24 Removed: | ".cpp" | ".cc" | ".cxx" | ".hpp" -> Some "cpp"
25 Removed: | ".cs" -> Some "csharp"
26 Removed: | ".css" -> Some "css"
27 Removed: | ".dart" -> Some "dart"
28 Removed: | ".diff" | ".patch" -> Some "diff"
29 Removed: | ".dune" -> Some "dune"
30 Removed: | ".el" | ".lisp" | ".cl" | ".scm" | ".ss" -> Some "lisp"
31 Removed: | ".erl" | ".hrl" -> Some "erlang"
32 Removed: | ".ex" | ".exs" -> Some "elixir"
33 Removed: | ".f90" | ".f95" | ".f03" | ".f08" | ".f" | ".for" -> Some "fortran"
34 Removed: | ".go" -> Some "go"
35 Removed: | ".gradle" | ".groovy" -> Some "groovy"
36 Removed: | ".hs" -> Some "haskell"
37 Removed: | ".html" | ".htm" -> Some "html"
38 Removed: | ".java" -> Some "java"
39 Removed: | ".jl" -> Some "julia"
40 Removed: | ".js" | ".mjs" | ".cjs" -> Some "javascript"
41 Removed: | ".json" -> Some "json"
42 Removed: | ".kt" | ".kts" -> Some "kotlin"
43 Removed: | ".lua" -> Some "lua"
44 Removed: | ".m" -> Some "objective-c"
45 Removed: | ".md" -> Some "markdown"
46 Removed: | ".nix" -> Some "nix"
47 Removed: | ".opam" -> Some "opam"
48 Removed: | ".org" -> Some "org"
49 Removed: | ".php" -> Some "php"
50 Removed: | ".pl" | ".pm" | ".t" -> Some "perl"
51 Removed: | ".proto" -> Some "protobuf"
52 Removed: | ".ps1" | ".psm1" | ".psd1" -> Some "powershell"
53 Removed: | ".py" -> Some "python"
54 Removed: | ".r" -> Some "r"
55 Removed: | ".rb" -> Some "ruby"
56 Removed: | ".rs" -> Some "rust"
57 Removed: | ".scala" | ".sc" -> Some "scala"
58 Removed: | ".sh" | ".bash" | ".zsh" -> Some "bash"
59 Removed: | ".sql" -> Some "sql"
60 Removed: | ".swift" -> Some "swift"
61 Removed: | ".tf" | ".hcl" -> Some "terraform"
62 Removed: | ".toml" -> Some "toml"
63 Removed: | ".ts" | ".tsx" -> Some "typescript"
64 Removed: | ".vue" -> Some "vue"
65 Removed: | ".xml" | ".svg" | ".xsl" -> Some "xml"
66 Removed: | ".yaml" | ".yml" -> Some "yaml"
67 Removed: | ".zig" -> Some "zig"
68 Removed: | _ -> None
19 Added: | _ -> (
20 Added: match Filename.extension name |> String.lowercase_ascii with
21 Added: | ".ml" | ".mli" -> Some "ocaml"
22 Added: | ".c" | ".h" -> Some "c"
23 Added: | ".clj" | ".cljs" | ".cljc" | ".edn" -> Some "clojure"
24 Added: | ".cpp" | ".cc" | ".cxx" | ".hpp" -> Some "cpp"
25 Added: | ".cs" -> Some "csharp"
26 Added: | ".css" -> Some "css"
27 Added: | ".dart" -> Some "dart"
28 Added: | ".diff" | ".patch" -> Some "diff"
29 Added: | ".dune" -> Some "dune"
30 Added: | ".el" | ".lisp" | ".cl" | ".scm" | ".ss" -> Some "lisp"
31 Added: | ".erl" | ".hrl" -> Some "erlang"
32 Added: | ".ex" | ".exs" -> Some "elixir"
33 Added: | ".f90" | ".f95" | ".f03" | ".f08" | ".f" | ".for" -> Some "fortran"
34 Added: | ".go" -> Some "go"
35 Added: | ".gradle" | ".groovy" -> Some "groovy"
36 Added: | ".hs" -> Some "haskell"
37 Added: | ".html" | ".htm" -> Some "html"
38 Added: | ".java" -> Some "java"
39 Added: | ".jl" -> Some "julia"
40 Added: | ".js" | ".mjs" | ".cjs" -> Some "javascript"
41 Added: | ".json" -> Some "json"
42 Added: | ".kt" | ".kts" -> Some "kotlin"
43 Added: | ".lua" -> Some "lua"
44 Added: | ".m" -> Some "objective-c"
45 Added: | ".md" -> Some "markdown"
46 Added: | ".nix" -> Some "nix"
47 Added: | ".opam" -> Some "opam"
48 Added: | ".org" -> Some "org"
49 Added: | ".php" -> Some "php"
50 Added: | ".pl" | ".pm" | ".t" -> Some "perl"
51 Added: | ".proto" -> Some "protobuf"
52 Added: | ".ps1" | ".psm1" | ".psd1" -> Some "powershell"
53 Added: | ".py" -> Some "python"
54 Added: | ".r" -> Some "r"
55 Added: | ".rb" -> Some "ruby"
56 Added: | ".rs" -> Some "rust"
57 Added: | ".scala" | ".sc" -> Some "scala"
58 Added: | ".sh" | ".bash" | ".zsh" -> Some "bash"
59 Added: | ".sql" -> Some "sql"
60 Added: | ".swift" -> Some "swift"
61 Added: | ".tf" | ".hcl" -> Some "terraform"
62 Added: | ".toml" -> Some "toml"
63 Added: | ".ts" | ".tsx" -> Some "typescript"
64 Added: | ".vue" -> Some "vue"
65 Added: | ".xml" | ".svg" | ".xsl" -> Some "xml"
66 Added: | ".yaml" | ".yml" -> Some "yaml"
67 Added: | ".zig" -> Some "zig"
68 Added: | _ -> None)
69 69
70 70 let of_shebang line =
71 71 if not (String.starts_with ~prefix:"#!" line) then None
lib/views/components.ml
index a522299c..bead870e 100644..100644
@@ -111,8 +111,7 @@
111 111 Ui.css_toggle ~id:"nav-toggle" ~toggle_class:"nav-toggle"
112 112 ~control_class:"nav-hamburger" ~label:"Menu" ~glyph:"\xe2\x8b\xae" ();
113 113 Ui.nav_links ~id:"nav-links"
114 Removed: (List.map (page_link repo ~active)
115 Removed: [ Summary; Commits; Files ]);
114 Added: (List.map (page_link repo ~active) [ Summary; Commits; Files ]);
116 115 ]
117 116
118 117 (** The same repository destinations as {!repo_nav}, condensed and pinned to the
@@ -162,22 +161,9 @@
162 161 let commit_type_badge ?href commit_type =
163 162 Ui.badge ~base_class:"commit-pill" ~variant:commit_type ?href commit_type
164 163
165 Removed: (** A README rendered below a listing. Level 3 because it sits under the page
166 Removed: heading and the listing it accompanies.
164 Added: (** A README displayed as semantic documentation.
167 165
168 Removed: When [filename] is given, language detection uses it to select the
169 Removed: appropriate grammar (e.g. ["README.md"] → markdown). When absent, the
170 Removed: content is highlighted as markdown by default since that covers the common
171 Removed: case. *)
172 Removed: let inline_readme ?filename content =
173 Removed: let language =
174 Removed: match filename with
175 Removed: | Some name -> Syntax.detect ~filename:(Some name) content
176 Removed: | None -> Some "markdown"
177 Removed: in
178 Removed: let lines = Highlight.highlight ~lang:language content in
179 Removed: Ui.region ~class_:"readme-inline"
180 Removed: [
181 Removed: Ui.heading ~level:3 [ Ui.text "README" ];
182 Removed: Ui.highlighted_code_listing ~class_:"blob" ~anchor_prefix:"readme-" lines;
183 Removed: ]
166 Added: Markdown and Org mode receive dedicated rendering; other README filenames
167 Added: use the Markdown-compatible fallback until additional formats are added. *)
168 Added: let inline_readme ?(filename = "README.md") content =
169 Added: Readme.render ~filename content
lib/views/layout.ml
index e3b3e001..4ff0f6c3 100644..100644
@@ -9,10 +9,7 @@
9 9 be built without depending on this module. They are re-exported here because
10 10 callers name them [Layout.Summary], [Layout.site], and so on. *)
11 11
12 Removed: type page = Components.page =
13 Removed: | Summary
14 Removed: | Commits
15 Removed: | Files
12 Added: type page = Components.page = Summary | Commits | Files
16 13
17 14 type site = Components.site = {
18 15 user_name : string;
@@ -33,10 +30,7 @@
33 30 }
34 31
35 32 let stylesheets =
36 Removed: [
37 Removed: "/static/styles.css";
38 Removed: "/static/syntax-theme.css";
39 Removed: ]
33 Added: [ "/static/styles.css"; "/static/syntax-theme.css"; "/static/readme.css" ]
40 34
41 35 let head page_title =
42 36 Ui.document_head ~title:page_title
lib/views/repo.ml
index 05e0ea7e..cec3a24c 100644..100644
@@ -192,7 +192,8 @@
192 192 let readme_panel =
193 193 match readme with
194 194 | None -> Ui.nothing
195 Removed: | Some (blob : Resolvers.Blob.t) -> Components.inline_readme blob.content
195 Added: | Some (readme : Resolvers.Readme.t) ->
196 Added: Components.inline_readme ~filename:readme.name readme.content
196 197 in
197 198 render_page context ~active:Summary
198 199 ~toolbar:
@@ -279,15 +280,12 @@
279 280 let files context trail (entries : Resolvers.Tree.tree_node list) =
280 281 render_page context ~active:Files
281 282 ~toolbar:[ path_trail context.repo trail ]
282 Removed: [
283 Removed: Ui.items_of ~id:"file-tree" (tree_row context.repo) entries;
284 Removed: ]
283 Added: [ Ui.items_of ~id:"file-tree" (tree_row context.repo) entries ]
285 284
286 285 let file context trail (blob : Resolvers.Blob.t) =
287 286 let filename =
288 287 match List.rev trail with (name, _) :: _ -> Some name | [] -> None
289 288 in
290 Removed: let language = Syntax.detect ~filename blob.content in
291 289 let raw_link =
292 290 match List.rev trail with
293 291 | (_, hash) :: _ ->
@@ -295,13 +293,17 @@
295 293 [ Components.route_link (Raw_file (context.repo, hash)) "View raw" ]
296 294 | [] -> Ui.nothing
297 295 in
296 Added: let body =
297 Added: match filename with
298 Added: | Some filename when Readme.is_readme_filename filename ->
299 Added: Readme.render ~filename blob.content
300 Added: | _ ->
301 Added: let language = Syntax.detect ~filename blob.content in
302 Added: let lines = Highlight.highlight ~lang:language blob.content in
303 Added: Ui.highlighted_code_listing ~id:"blob" lines
304 Added: in
298 305 let toolbar = [ path_trail context.repo trail ] in
299 Removed: render_page context ~active:Files ~toolbar
300 Removed: [
301 Removed: raw_link;
302 Removed: (let lines = Highlight.highlight ~lang:language blob.content in
303 Removed: Ui.highlighted_code_listing ~id:"blob" lines);
304 Removed: ]
306 Added: render_page context ~active:Files ~toolbar [ raw_link; body ]
305 307
306 308 let commit context (commit : Resolvers.Commit.t) diff =
307 309 let message = parse_commit_message commit.message in
@@ -386,4 +388,3 @@
386 388 @ [ metadata ]
387 389 @ Ui.Diff.view ~empty_message:"No file changes in this commit."
388 390 (List.map diff_file diff))
389 Removed:
lib/views/ui.ml
index b3339958..bafd1e9f 100644..100644
@@ -70,7 +70,7 @@
70 70 let paragraph ?class_ children = HTML.p (opt_class class_) children
71 71 let paragraph_text ?class_ value = paragraph ?class_ [ text value ]
72 72
73 Removed: let heading ?(level = 1) ?class_ children =
73 Added: let heading ?id ?(level = 1) ?class_ children =
74 74 let element =
75 75 match level with
76 76 | 1 -> HTML.h1
@@ -80,7 +80,10 @@
80 80 | 5 -> HTML.h5
81 81 | _ -> HTML.h6
82 82 in
83 Removed: element (opt_class class_) children
83 Added: element (opt_id id @ opt_class class_) children
84 Added:
85 Added: let code_block ?class_ children =
86 Added: HTML.pre (opt_class class_) [ HTML.code [] children ]
84 87
85 88 (* Lists *)
86 89
lib/views/ui.mli
index bae008a2..756aedad 100644..100644
@@ -97,12 +97,17 @@
97 97 val paragraph : ?class_:string -> node list -> node
98 98 val paragraph_text : ?class_:string -> string -> node
99 99
100 Removed: val heading : ?level:int -> ?class_:string -> node list -> node
100 Added: val heading : ?id:string -> ?level:int -> ?class_:string -> node list -> node
101 101 (** A heading. [level] follows the document outline: 1 for the page's subject, 2
102 102 and 3 for nested sections. Skipping levels breaks screen-reader navigation,
103 103 so pass the level that matches the structure rather than the one that looks
104 Removed: right. Levels outside 1–6 clamp to 6. *)
104 Added: right. Levels outside 1–6 clamp to 6. [id] provides a local fragment target.
105 Added: *)
105 106
107 Added: val code_block : ?class_:string -> node list -> node
108 Added: (** A preformatted code block without line numbers. The children can be escaped
109 Added: text or server-rendered syntax-highlight spans. *)
110 Added:
106 111 (** {1 Lists} *)
107 112
108 113 val items : ?id:string -> ?class_:string -> node list -> node
@@ -301,17 +306,21 @@
301 306 highlighted. [anchor_prefix] namespaces those anchors, which is required
302 307 when one page shows more than one listing.
303 308
304 Removed: Content is emitted verbatim as plain text with no syntax colouring.
305 Removed: For highlighted output, use {!highlighted_code_listing} instead. *)
309 Added: Content is emitted verbatim as plain text with no syntax colouring. For
310 Added: highlighted output, use {!highlighted_code_listing} instead. *)
306 311
307 312 val highlighted_code_listing :
308 Removed: ?id:string -> ?class_:string -> ?anchor_prefix:string -> node list list -> node
313 Added: ?id:string ->
314 Added: ?class_:string ->
315 Added: ?anchor_prefix:string ->
316 Added: node list list ->
317 Added: node
309 318 (** A line-numbered listing of pre-highlighted source code.
310 319
311 320 Like {!code_listing} but accepts lines already tokenized into styled spans
312 Removed: (e.g. from {!Highlight.highlight}). Each inner list represents one line's
313 Removed: worth of nodes; the function adds line numbers and anchors in the same
314 Removed: grid layout as [code_listing]. *)
321 Added: (e.g. from {!Highlight.highlight}). Each inner list represents one line's
322 Added: worth of nodes; the function adds line numbers and anchors in the same grid
323 Added: layout as [code_listing]. *)
315 324
316 325 (** {1 Diffs} *)
317 326
test/test_ogit.ml
index a23087b2..4b166500 100644..100644
@@ -12,5 +12,6 @@
12 12 Test_dispatch.suite;
13 13 Test_list_ext.suite;
14 14 Test_diff.suite;
15 Added: Test_readme.suite;
15 16 Test_views.suite;
16 17 ]
test/test_readme.ml
index 00000000..e6020a32 000000..100644
@@ -0,0 +1,76 @@
1 Added: (** README documentation rendering. *)
2 Added:
3 Added: let contains text fragment =
4 Added: let text_length = String.length text in
5 Added: let fragment_length = String.length fragment in
6 Added: let rec loop index =
7 Added: if index > text_length - fragment_length then false
8 Added: else if String.sub text index fragment_length = fragment then true
9 Added: else loop (index + 1)
10 Added: in
11 Added: fragment_length <= text_length && loop 0
12 Added:
13 Added: let render ~filename content =
14 Added: Ogit.Readme.render ~filename content |> Dream_html.to_string
15 Added:
16 Added: let test_markdown_document () =
17 Added: let html =
18 Added: render ~filename:"README.md"
19 Added: "# Project\n\nIntroduction.\n\n## Installation\n\n- Download\n- Run\n"
20 Added: in
21 Added: Alcotest.(check bool)
22 Added: "documentation container" true
23 Added: (contains html "readme-document readme-markdown");
24 Added: Alcotest.(check bool) "h1 heading" true (contains html "<h1 id=\"project\"");
25 Added: Alcotest.(check bool)
26 Added: "heading fragment link" true
27 Added: (contains html "<a href=\"#project\" class=\"readme-heading-link\">Project");
28 Added: Alcotest.(check bool)
29 Added: "nested h2 heading" true
30 Added: (contains html "<h2 id=\"installation\"");
31 Added: Alcotest.(check bool)
32 Added: "no line-number anchors" false
33 Added: (contains html "line-anchor")
34 Added:
35 Added: let test_org_document () =
36 Added: let html =
37 Added: render ~filename:"README.org"
38 Added: "#+TITLE: Org Project\n\
39 Added: #+AUTHOR: Ada Lovelace\n\
40 Added: #+DATE: 2026-08-01\n\n\
41 Added: * Overview\n\n\
42 Added: #+BEGIN_SRC ocaml\n\
43 Added: let answer = 42\n\
44 Added: #+END_SRC\n"
45 Added: in
46 Added: Alcotest.(check bool)
47 Added: "Org document container" true
48 Added: (contains html "readme-document readme-org");
49 Added: Alcotest.(check bool)
50 Added: "title heading" true
51 Added: (contains html "<h1 id=\"org-project\"");
52 Added: Alcotest.(check bool)
53 Added: "metadata definition list" true
54 Added: (contains html "<dl class=\"readme-org-metadata\"");
55 Added: Alcotest.(check bool) "author metadata" true (contains html "<dt>Author</dt>");
56 Added: Alcotest.(check bool) "Org heading" true (contains html "<h1 id=\"overview\"");
57 Added: Alcotest.(check bool)
58 Added: "highlighted source block" true
59 Added: (contains html "source.ocaml-keyword")
60 Added:
61 Added: let test_readme_filename_detection () =
62 Added: Alcotest.(check bool)
63 Added: "case-insensitive README name" true
64 Added: (Ogit.Readme.is_readme_filename "ReadMe.ORG");
65 Added: Alcotest.(check bool)
66 Added: "ordinary file is not a README" false
67 Added: (Ogit.Readme.is_readme_filename "guide.md")
68 Added:
69 Added: let suite =
70 Added: ( "readme",
71 Added: [
72 Added: Alcotest.test_case "Markdown document" `Quick test_markdown_document;
73 Added: Alcotest.test_case "Org document" `Quick test_org_document;
74 Added: Alcotest.test_case "README filename detection" `Quick
75 Added: test_readme_filename_detection;
76 Added: ] )