zuka
zuka/src/web/html.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/web/html.rs
RShtml.rs65.4 KBDownload
1// HTML construction: escaping, markdown, the tag dialect, the page shell and the
2// stylesheet.
3//
4// Everything user-controlled — repository names, paths, commit messages, file
5// contents — reaches a browser through this module, so escaping is centralised
6// here. There is no template engine: a template language would be a second syntax
7// to audit for the one thing that matters, which is that nothing interpolated is
8// ever trusted.
9//
10// Markdown is more than a formatter on this surface. The repository's front page
11// *is* its README, rendered as a document — in the way Markdoc turns a Stripe doc
12// file into a site — so this module also carries a small block-tag dialect
13// (`{% hero %}`, `{% callout %}`, `{% cards %}`, `{% details %}`) and resolves
14// relative links between files, which is what turns a folder of markdown into
15// something a reader can actually walk through.
16
17use crate::brand;
18
19/// Escape text for HTML body or attribute context.
20///
21/// Handles both contexts, including quotes, so a caller cannot pick the wrong one.
22/// Single quotes are escaped too, because an attribute written with single quotes is
23/// otherwise an injection point and this function has no way to know which the
24/// caller used.
25pub fn escape(value: &str) -> String {
26 let mut out = String::with_capacity(value.len());
27 for c in value.chars() {
28 match c {
29 '&' => out.push_str("&"),
30 '<' => out.push_str("&lt;"),
31 '>' => out.push_str("&gt;"),
32 '"' => out.push_str("&quot;"),
33 '\'' => out.push_str("&#39;"),
34 _ => out.push(c),
35 }
36 }
37 out
38}
39
40/// Percent-encode a path segment for use in a URL.
41///
42/// Repository paths carry characters — `#`, `?`, spaces — that silently truncate or
43/// redirect a link if they reach an href raw.
44pub fn url_escape(value: &str) -> String {
45 let mut out = String::with_capacity(value.len());
46 for byte in value.as_bytes() {
47 match byte {
48 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
49 out.push(*byte as char)
50 }
51 _ => out.push_str(&format!("%{byte:02X}")),
52 }
53 }
54 out
55}
56
57// ── markdown ────────────────────────────────────────────────────────────────
58
59/// Where a markdown document lives, so its relative links can work.
60///
61/// `[guide](docs/guide.md)` in a README should open that file's rendered page, and
62/// `![shot](docs/shot.png)` should show the image — without this, every relative
63/// link 404s and a README can never grow into a site.
64pub struct LinkBase {
65 /// Canonical repo prefix, `/{account}/{repo}`. Built from validated names.
66 pub repo: String,
67 /// The ref the document was read at, as a short name.
68 pub reference: String,
69 /// Directory holding the document within the repository, `""` at the root.
70 pub dir: String,
71}
72
73/// Render a markdown document with the block-tag dialect.
74///
75/// The dialect is a deliberately small subset of Markdoc's syntax: a tag sits alone
76/// on a line, `{% name key="value" %}` opens and `{% /name %}` closes, and between
77/// tags everything is ordinary markdown. Recognised tags become styled wrappers;
78/// an unrecognised tag line is dropped while its content still renders, so a
79/// document written for a richer engine degrades to its text rather than to noise.
80///
81/// Tag lines inside fenced code blocks are left alone — a README documenting the
82/// dialect must be able to show it.
83pub fn readme(source: &str, base: &LinkBase) -> String {
84 let mut out = String::new();
85 let mut buffer = String::new();
86 let mut open: Vec<(String, &'static str)> = Vec::new();
87 let mut fenced = false;
88
89 let flush = |buffer: &mut String, out: &mut String| {
90 if !buffer.is_empty() {
91 out.push_str(&render_markdown(buffer, Some(base)));
92 buffer.clear();
93 }
94 };
95
96 for line in source.lines() {
97 let lead = line.trim_start();
98 if lead.starts_with("```") || lead.starts_with("~~~") {
99 fenced = !fenced;
100 }
101
102 // Tags are recognised only at column zero and outside code fences, so
103 // indented examples and fenced samples never trigger the machinery.
104 if fenced || !line.starts_with("{%") {
105 buffer.push_str(line);
106 buffer.push('\n');
107 continue;
108 }
109
110 let Some(token) = tag_token(line) else {
111 buffer.push_str(line);
112 buffer.push('\n');
113 continue;
114 };
115
116 flush(&mut buffer, &mut out);
117 match token {
118 Token::Ignored => {}
119 Token::Open { name, attrs } => {
120 if let Some((opening, closing)) = wrapper(&name, &attrs) {
121 out.push_str(&opening);
122 open.push((name, closing));
123 }
124 // Unknown tag: the line disappears, the content stays.
125 }
126 Token::Close(name) => {
127 // Lenient closing: pop to the named tag if it is open, emitting the
128 // wrappers in between, and ignore a close that matches nothing. A
129 // reader's document should never break over a typo.
130 if open.iter().any(|(n, _)| *n == name) {
131 while let Some((n, closing)) = open.pop() {
132 out.push_str(closing);
133 if n == name {
134 break;
135 }
136 }
137 }
138 }
139 }
140 }
141
142 flush(&mut buffer, &mut out);
143 while let Some((_, closing)) = open.pop() {
144 out.push_str(closing);
145 }
146 out
147}
148
149/// Render markdown to HTML with raw HTML dropped.
150///
151/// A README is attacker-controlled on any public host: anyone who can push can put
152/// a `<script>` in it. pulldown-cmark passes raw HTML through by default, so the
153/// tags are filtered out here rather than escaped — escaping would show the markup
154/// as literal text, which is noise, while dropping it renders the readable content
155/// and none of the payload.
156///
157/// Headings are given generated ids so a long document can be linked into and can
158/// link within itself — a document that is the repository's front page needs a
159/// table of contents to be possible.
160fn render_markdown(source: &str, base: Option<&LinkBase>) -> String {
161 use pulldown_cmark::{html, CowStr, Event, Options, Parser, Tag, TagEnd};
162 use std::collections::HashMap;
163
164 let mut options = Options::empty();
165 options.insert(Options::ENABLE_TABLES);
166 options.insert(Options::ENABLE_STRIKETHROUGH);
167 options.insert(Options::ENABLE_TASKLISTS);
168 options.insert(Options::ENABLE_FOOTNOTES);
169
170 let mut events: Vec<Event> = Parser::new_ext(source, options)
171 .filter(|event| {
172 !matches!(
173 event,
174 Event::Html(_) | Event::InlineHtml(_) | Event::FootnoteReference(_)
175 )
176 })
177 .map(|event| match event {
178 // pulldown-cmark emits link and image destinations verbatim, so
179 // `[x](javascript:…)` in a README becomes an executable href. Dropping
180 // the tags is not enough; the URLs have to be checked too.
181 Event::Start(Tag::Link {
182 link_type,
183 dest_url,
184 title,
185 id,
186 }) => Event::Start(Tag::Link {
187 link_type,
188 dest_url: rewrite_url(&dest_url, base, false).into(),
189 title,
190 id,
191 }),
192 Event::Start(Tag::Image {
193 link_type,
194 dest_url,
195 title,
196 id,
197 }) => Event::Start(Tag::Image {
198 link_type,
199 dest_url: rewrite_url(&dest_url, base, true).into(),
200 title,
201 id,
202 }),
203 other => other,
204 })
205 .collect();
206
207 // Second pass: assign heading ids. The heading's text arrives in the events
208 // after its start tag, so ids cannot be set in a single streaming pass.
209 let mut seen: HashMap<String, usize> = HashMap::new();
210 for i in 0..events.len() {
211 let Event::Start(Tag::Heading { id: None, .. }) = &events[i] else {
212 continue;
213 };
214 let mut text = String::new();
215 for later in events[i + 1..].iter() {
216 match later {
217 Event::End(TagEnd::Heading(_)) => break,
218 Event::Text(t) | Event::Code(t) => text.push_str(t),
219 _ => {}
220 }
221 }
222 let slug = slugify(&text);
223 if slug.is_empty() {
224 continue;
225 }
226 let n = seen
227 .entry(slug.clone())
228 .and_modify(|n| *n += 1)
229 .or_insert(1);
230 let unique = if *n > 1 { format!("{slug}-{n}") } else { slug };
231 if let Event::Start(Tag::Heading { id, .. }) = &mut events[i] {
232 *id = Some(CowStr::from(unique));
233 }
234 }
235
236 let mut out = String::new();
237 html::push_html(&mut out, events.into_iter());
238 out
239}
240
241/// A heading's anchor: lowercase, alphanumerics kept, runs of anything else
242/// collapsed to one dash. Empty when the heading has no usable text.
243fn slugify(text: &str) -> String {
244 let mut out = String::new();
245 let mut pending_dash = false;
246 for c in text.chars() {
247 if c.is_alphanumeric() {
248 if pending_dash && !out.is_empty() {
249 out.push('-');
250 }
251 pending_dash = false;
252 out.extend(c.to_lowercase());
253 } else {
254 pending_dash = true;
255 }
256 }
257 out
258}
259
260// ── URL policy ──────────────────────────────────────────────────────────────
261
262/// The scheme of a URL, if it has one, parsed the way a browser would.
263///
264/// Control characters and whitespace are stripped first: `java\tscript:` and
265/// `java\nscript:` are both parsed as the scheme by browsers but defeat a naive
266/// prefix comparison. A `:` appearing after `/`, `?` or `#` is path punctuation,
267/// not a scheme.
268fn scheme_of(url: &str) -> Option<String> {
269 let cleaned: String = url
270 .chars()
271 .filter(|c| !c.is_whitespace() && !c.is_control())
272 .collect();
273 let (scheme, _) = cleaned.split_once(':')?;
274 if scheme.contains('/') || scheme.contains('?') || scheme.contains('#') {
275 return None;
276 }
277 Some(scheme.to_ascii_lowercase())
278}
279
280/// Replace a URL that would execute with one that does nothing.
281///
282/// An allow-list of schemes, not a block-list: `javascript:` is the obvious case but
283/// `data:` can carry HTML and `vbscript:` still runs in some engines, and a
284/// block-list is a list of the attacks somebody already thought of. Anything without
285/// a scheme is a relative link and is left alone.
286fn safe_url(url: &str) -> String {
287 const ALLOWED: [&str; 4] = ["http", "https", "mailto", "ftp"];
288 match scheme_of(url) {
289 None => url.to_string(),
290 Some(scheme) if ALLOWED.contains(&scheme.as_str()) => url.to_string(),
291 // Rendered, but inert. Removing the link entirely would silently change what
292 // the document says.
293 Some(_) => "#blocked".to_string(),
294 }
295}
296
297/// Apply the scheme policy and, when the document's location is known, make
298/// relative links land somewhere real.
299///
300/// A relative link is resolved against the document's own directory and becomes a
301/// viewer URL: another markdown file opens as a rendered page, an image is served
302/// as raw bytes, and a directory opens as a listing. Absolute paths and anchors are
303/// left alone. Without a base — no document location — relative links pass through
304/// untouched, which is the old behaviour and still correct for text with no home.
305fn rewrite_url(url: &str, base: Option<&LinkBase>, image: bool) -> String {
306 let Some(base) = base else {
307 return safe_url(url);
308 };
309 if url.is_empty() || url.starts_with('#') {
310 return url.to_string();
311 }
312 if scheme_of(url).is_some() {
313 return safe_url(url);
314 }
315 if url.starts_with('/') {
316 return url.to_string();
317 }
318
319 let (before_fragment, fragment) = match url.split_once('#') {
320 Some((p, f)) => (p, Some(f)),
321 None => (url, None),
322 };
323 // `README.md?plain=1` is a normal viewer link, not a file whose literal
324 // name contains `?plain=1`. Keep the query outside the repository path;
325 // it remains same-origin and the HTML renderer escapes it in the href.
326 let (path_part, query) = match before_fragment.split_once('?') {
327 Some((p, q)) => (p, Some(q)),
328 None => (before_fragment, None),
329 };
330
331 let joined = join_relative(&base.dir, path_part);
332 let verb = if image {
333 "media"
334 } else if joined.is_empty() || path_part.ends_with('/') {
335 // `.` or `docs/` name a directory, and a directory is a listing.
336 "tree"
337 } else {
338 "blob"
339 };
340
341 let mut out = format!(
342 "{}/{}/{}/{}",
343 base.repo,
344 verb,
345 url_escape(&base.reference),
346 url_escape(&joined)
347 );
348 if let Some(q) = query.filter(|q| !q.is_empty()) {
349 out.push('?');
350 out.push_str(q);
351 }
352 if let Some(f) = fragment {
353 out.push('#');
354 out.push_str(f);
355 }
356 out
357}
358
359/// Join a relative path onto a directory, clamped at the repository root.
360///
361/// `..` pops a component and can never climb above the root — the result is a
362/// repository path, not a filesystem one, and the viewer resolves it through git,
363/// so the worst a hostile path can reach is a 404.
364fn join_relative(dir: &str, relative: &str) -> String {
365 let mut parts: Vec<&str> = dir.split('/').filter(|p| !p.is_empty()).collect();
366 for segment in relative.split('/') {
367 match segment {
368 "" | "." => {}
369 ".." => {
370 parts.pop();
371 }
372 other => parts.push(other),
373 }
374 }
375 parts.join("/")
376}
377
378// ── the tag dialect ─────────────────────────────────────────────────────────
379
380enum Token {
381 Open {
382 name: String,
383 attrs: Vec<(String, String)>,
384 },
385 Close(String),
386 /// Syntactically a tag, semantically nothing — dropped from the output.
387 Ignored,
388}
389
390/// Parse one line as a tag, if it is one.
391///
392/// The whole line must be the tag: `{%` at column zero, `%}` at the end. Anything
393/// else is content and stays content, so a stray `{%` mid-sentence cannot eat the
394/// rest of a document.
395fn tag_token(line: &str) -> Option<Token> {
396 let trimmed = line.trim_end();
397 if !trimmed.starts_with("{%") || !trimmed.ends_with("%}") || trimmed.len() < 4 {
398 return None;
399 }
400 let inner = trimmed[2..trimmed.len() - 2].trim();
401 if inner.is_empty() {
402 return Some(Token::Ignored);
403 }
404
405 if let Some(rest) = inner.strip_prefix('/') {
406 return Some(Token::Close(rest.trim().to_ascii_lowercase()));
407 }
408
409 // Self-closing tags exist in Markdoc for void elements; this dialect has no
410 // void elements, so they are recognised and dropped rather than half-opened.
411 let (body, self_closing) = match inner.strip_suffix('/') {
412 Some(stripped) => (stripped.trim(), true),
413 None => (inner, false),
414 };
415
416 let name: String = body
417 .chars()
418 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
419 .collect();
420 if name.is_empty() {
421 return Some(Token::Ignored);
422 }
423 if self_closing {
424 return Some(Token::Ignored);
425 }
426 let attrs = parse_attrs(&body[name.len()..]);
427 Some(Token::Open {
428 name: name.to_ascii_lowercase(),
429 attrs,
430 })
431}
432
433/// `key="value"` pairs. Values run to the next quote with no escapes — the dialect
434/// is for titles and summaries, not for programs.
435fn parse_attrs(input: &str) -> Vec<(String, String)> {
436 let mut out = Vec::new();
437 let mut chars = input.chars().peekable();
438 loop {
439 while chars.next_if(|c| c.is_whitespace()).is_some() {}
440 let mut key = String::new();
441 while let Some(c) = chars.next_if(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') {
442 key.push(c);
443 }
444 if key.is_empty() {
445 break;
446 }
447 if chars.next_if_eq(&'=').is_none() || chars.next_if_eq(&'"').is_none() {
448 break;
449 }
450 let mut value = String::new();
451 loop {
452 match chars.next() {
453 Some('"') => break,
454 Some(c) => value.push(c),
455 None => return out,
456 }
457 }
458 out.push((key.to_ascii_lowercase(), value));
459 }
460 out
461}
462
463/// A tone name mapped to a fixed class, or nothing.
464///
465/// The palette is an allow-list for the same reason the URL schemes are: an
466/// attribute value must never be able to invent a class, so the value is matched
467/// and a constant is emitted — user text never reaches the class attribute.
468fn tone_class(value: Option<&str>) -> &'static str {
469 match value {
470 Some("sun") => " md-toned tone-sun",
471 Some("mint") => " md-toned tone-mint",
472 Some("sky") => " md-toned tone-sky",
473 Some("rose") => " md-toned tone-rose",
474 Some("grape") => " md-toned tone-grape",
475 Some("ink") => " md-toned tone-ink",
476 _ => "",
477 }
478}
479
480/// The HTML a recognised tag opens and closes with.
481///
482/// Every attribute value is escaped, tag names are matched against this list and
483/// nothing else, and no attribute ever becomes a URL — so the dialect adds no
484/// injection surface beyond what `escape` already guards. Styling attributes
485/// (`tone`, `align`, `columns`) go through allow-lists that emit constants.
486fn wrapper(name: &str, attrs: &[(String, String)]) -> Option<(String, &'static str)> {
487 let get = |key: &str| {
488 attrs
489 .iter()
490 .find(|(k, _)| k == key)
491 .map(|(_, v)| v.as_str())
492 };
493
494 match name {
495 "hero" => {
496 let align = match get("align") {
497 Some("left") => " md-left",
498 _ => "",
499 };
500 Some((
501 format!(
502 "<section class=\"md-hero{}{align}\">",
503 tone_class(get("tone"))
504 ),
505 "</section>",
506 ))
507 }
508 // A full-width painted stripe: the element that makes a README read as a
509 // landing page rather than a document.
510 "band" => Some((
511 format!("<section class=\"md-band{}\">", tone_class(get("tone"))),
512 "</section>",
513 )),
514 // A display-size statement. Tone colours the words, not the ground.
515 "big" => Some((
516 format!("<div class=\"md-big{}\">", tone_class(get("tone"))),
517 "</div>",
518 )),
519 // Semantic wrappers rather than a gallery of decorative widgets: an
520 // authored visual gets a frame; an ordinary markdown list becomes a route.
521 "visual" => Some(("<figure class=\"md-visual\">".to_string(), "</figure>")),
522 "steps" => Some(("<section class=\"md-steps\">".to_string(), "</section>")),
523 "callout" | "note" | "tip" | "warn" | "warning" | "danger" => {
524 let kind = match name {
525 "callout" => get("type").unwrap_or("note"),
526 other => other,
527 };
528 let kind = match kind {
529 "tip" => "tip",
530 "warn" | "warning" | "caution" => "warn",
531 "danger" | "error" => "danger",
532 _ => "note",
533 };
534 let mut opening = format!("<aside class=\"md-callout md-{kind}\">");
535 if let Some(title) = get("title") {
536 opening.push_str(&format!(
537 "<p class=\"md-callout-title\">{}</p>",
538 escape(title)
539 ));
540 }
541 Some((opening, "</aside>"))
542 }
543 // <details> is the one disclosure widget that works with no script, which
544 // is what this surface has.
545 "details" => Some((
546 format!(
547 "<details class=\"md-details\"><summary>{}</summary><div class=\"md-details-body\">",
548 escape(get("summary").unwrap_or("More"))
549 ),
550 "</div></details>",
551 )),
552 "cards" => {
553 let columns = match get("columns") {
554 Some("2") => " md-cols-2",
555 Some("3") => " md-cols-3",
556 Some("4") => " md-cols-4",
557 _ => "",
558 };
559 Some((format!("<div class=\"md-cards{columns}\">"), "</div>"))
560 }
561 "card" => {
562 let mut opening = format!("<div class=\"md-card{}\">", tone_class(get("tone")));
563 if let Some(title) = get("title") {
564 opening.push_str(&format!("<p class=\"md-card-title\">{}</p>", escape(title)));
565 }
566 Some((opening, "</div>"))
567 }
568 _ => None,
569 }
570}
571
572// ── the shell ───────────────────────────────────────────────────────────────
573
574/// URL of the stylesheet, carrying the build so a deploy cannot serve stale CSS
575/// from a cache told to keep it for a year.
576pub fn style_url() -> String {
577 format!("/_{}/{}.css", brand::NAME, brand::COMMIT)
578}
579
580pub struct Page {
581 pub title: String,
582 /// Rendered into the header bar, already escaped.
583 pub heading: String,
584 pub body: String,
585 /// Search/social metadata for a public repository overview.
586 ///
587 /// Other browser pages deliberately omit it: a file view is not a share card,
588 /// and a private page must never advertise its title or image to a crawler.
589 pub seo: Option<Seo>,
590 /// Full-bleed layout: the body manages its own measure, so painted bands can
591 /// reach the viewport edge. The README-as-site page uses this; every ordinary
592 /// page stays in the centred column.
593 pub wide: bool,
594}
595
596pub struct Seo {
597 pub description: String,
598 pub canonical: String,
599 pub image: Option<String>,
600 pub image_alt: Option<String>,
601}
602
603/// Wrap a page body in the shell.
604pub fn document(page: &Page) -> String {
605 let mut out = String::with_capacity(page.body.len() + 2048);
606 out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
607 out.push_str("<meta charset=\"utf-8\">\n");
608 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
609 out.push_str(&format!("<title>{}</title>\n", escape(&page.title)));
610 if let Some(seo) = &page.seo {
611 let title = escape(&page.title);
612 let description = escape(&seo.description);
613 let canonical = escape(&seo.canonical);
614 out.push_str(&format!(
615 "<meta name=\"description\" content=\"{description}\">\n"
616 ));
617 out.push_str(&format!("<link rel=\"canonical\" href=\"{canonical}\">\n"));
618 out.push_str(&format!(
619 "<meta property=\"og:title\" content=\"{title}\">\n"
620 ));
621 out.push_str(&format!(
622 "<meta property=\"og:description\" content=\"{description}\">\n"
623 ));
624 out.push_str("<meta property=\"og:type\" content=\"website\">\n");
625 out.push_str(&format!(
626 "<meta property=\"og:url\" content=\"{canonical}\">\n"
627 ));
628 out.push_str(&format!(
629 "<meta property=\"og:site_name\" content=\"{}\">\n",
630 escape(brand::NAME)
631 ));
632 out.push_str(&format!(
633 "<meta name=\"twitter:card\" content=\"{}\">\n",
634 if seo.image.is_some() {
635 "summary_large_image"
636 } else {
637 "summary"
638 }
639 ));
640 out.push_str(&format!(
641 "<meta name=\"twitter:title\" content=\"{title}\">\n"
642 ));
643 out.push_str(&format!(
644 "<meta name=\"twitter:description\" content=\"{description}\">\n"
645 ));
646 if let Some(image) = &seo.image {
647 let image = escape(image);
648 let alt = escape(seo.image_alt.as_deref().unwrap_or(&seo.description));
649 out.push_str(&format!(
650 "<meta property=\"og:image\" content=\"{image}\">\n"
651 ));
652 out.push_str("<meta property=\"og:image:width\" content=\"1200\">\n");
653 out.push_str("<meta property=\"og:image:height\" content=\"630\">\n");
654 out.push_str(&format!(
655 "<meta property=\"og:image:alt\" content=\"{alt}\">\n"
656 ));
657 out.push_str(&format!(
658 "<meta name=\"twitter:image\" content=\"{image}\">\n"
659 ));
660 out.push_str(&format!(
661 "<meta name=\"twitter:image:alt\" content=\"{alt}\">\n"
662 ));
663 }
664 }
665 // Dark and light are both first-class; the browser picks. Declaring this stops
666 // form controls and scrollbars rendering light on a dark page.
667 out.push_str("<meta name=\"color-scheme\" content=\"light dark\">\n");
668 out.push_str(&format!(
669 "<link rel=\"stylesheet\" href=\"{}\">\n",
670 style_url()
671 ));
672 out.push_str("</head>\n<body>\n");
673
674 out.push_str("<header class=\"bar\">\n<div class=\"wrap bar-inner\">\n");
675 out.push_str(&format!(
676 "<a class=\"brand\" href=\"/\">{}</a>\n",
677 escape(brand::NAME)
678 ));
679 out.push_str(&format!("<div class=\"crumbs\">{}</div>\n", page.heading));
680 out.push_str("</div>\n</header>\n");
681
682 if page.wide {
683 out.push_str("<main>\n");
684 } else {
685 out.push_str("<main class=\"wrap\">\n");
686 }
687 out.push_str(&page.body);
688 out.push_str("\n</main>\n");
689
690 out.push_str("<footer class=\"wrap foot\">\n");
691 out.push_str(&format!(
692 "<span>served by {} {}</span>\n",
693 escape(brand::NAME),
694 escape(brand::VERSION)
695 ));
696 out.push_str("</footer>\n");
697
698 out.push_str("</body>\n</html>\n");
699 out
700}
701
702pub const STYLE: &str = r#"
703/* Repository viewer.
704
705 No framework and no build step: the service ships as one binary, and a
706 stylesheet that needed compiling would mean a toolchain in the release path.
707
708 Written for people who have never used a code host: soft cards, plain words,
709 big touch targets, and a repository front page that reads as a document. */
710
711*, *::before, *::after { box-sizing: border-box; }
712
713:root {
714 --bg: #f6f7fb;
715 --card: #ffffff;
716 --raised: #f1f2f8;
717 --line: #e2e4ef;
718 --ink: #191d27;
719 --muted: #646c7f;
720 --link: #2f56d8;
721 --accent: #6c53e6;
722 --accent-soft: #efeaff;
723 --ok-bg: #e2f6ea;
724 --ok-ink: #157a43;
725 --warn-bg: #fdf2dc;
726 --warn-ink: #8a5a08;
727 --bad-bg: #fde8ec;
728 --bad-ink: #c0334b;
729 --note-bg: #e9f0fe;
730 --note-ink: #2d63c8;
731 --radius: 14px;
732 --radius-small: 8px;
733 --shadow: 0 1px 2px rgba(20, 24, 40, 0.05), 0 8px 24px -18px rgba(20, 24, 40, 0.25);
734 --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo,
735 Consolas, "Liberation Mono", monospace;
736 --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
737 Arial, sans-serif;
738}
739
740@media (prefers-color-scheme: dark) {
741 :root {
742 --bg: #0e1117;
743 --card: #161b24;
744 --raised: #1c222d;
745 --line: #2a313e;
746 --ink: #e8eaf1;
747 --muted: #98a1b3;
748 --link: #82aaff;
749 --accent: #a08dff;
750 --accent-soft: #262040;
751 --ok-bg: #123322;
752 --ok-ink: #5fd08a;
753 --warn-bg: #34280f;
754 --warn-ink: #e0b055;
755 --bad-bg: #3a1a22;
756 --bad-ink: #ff8ba0;
757 --note-bg: #16233c;
758 --note-ink: #8ab2ff;
759 --shadow: none;
760 }
761}
762
763html { -webkit-text-size-adjust: 100%; }
764
765body {
766 margin: 0;
767 background: var(--bg);
768 color: var(--ink);
769 font-family: var(--sans);
770 font-size: 15.5px;
771 line-height: 1.65;
772}
773
774.wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; }
775
776a { color: var(--link); text-decoration: none; }
777a:hover { text-decoration: underline; }
778
779/* ── header ─────────────────────────────────────────────────────────────── */
780
781.bar {
782 border-bottom: 1px solid var(--line);
783 background: var(--card);
784 position: sticky;
785 top: 0;
786 z-index: 10;
787}
788
789.bar-inner {
790 display: flex;
791 align-items: center;
792 gap: 14px;
793 height: 54px;
794}
795
796.brand {
797 font-weight: 700;
798 letter-spacing: -0.01em;
799 color: var(--ink);
800 font-size: 17px;
801}
802.brand:hover { text-decoration: none; color: var(--accent); }
803
804.crumbs {
805 font-size: 14px;
806 color: var(--muted);
807 overflow: hidden;
808 text-overflow: ellipsis;
809 white-space: nowrap;
810}
811.crumbs a { font-weight: 550; }
812.crumbs .sep { color: var(--line); margin: 0 3px; }
813
814/* ── repository head ────────────────────────────────────────────────────── */
815
816.repo-head { padding: 24px 0 0; }
817
818.repo-title {
819 margin: 0;
820 font-size: 27px;
821 font-weight: 700;
822 letter-spacing: -0.02em;
823 display: flex;
824 align-items: center;
825 gap: 10px;
826 flex-wrap: wrap;
827}
828.repo-owner { color: var(--muted); font-weight: 500; }
829
830.repo-desc { margin: 8px 0 0; color: var(--muted); max-width: 70ch; font-size: 16px; }
831
832.badge {
833 font-size: 11px;
834 font-weight: 650;
835 text-transform: uppercase;
836 letter-spacing: 0.07em;
837 padding: 3px 9px;
838 border-radius: 999px;
839 border: 1px solid var(--line);
840 color: var(--muted);
841 background: var(--card);
842}
843.badge.public { background: var(--ok-bg); color: var(--ok-ink); border-color: transparent; }
844
845.clone {
846 display: flex;
847 gap: 10px;
848 align-items: center;
849 flex-wrap: wrap;
850 margin-top: 16px;
851}
852.clone-label { font-size: 13px; color: var(--muted); }
853.clone code {
854 font-family: var(--mono);
855 font-size: 12.5px;
856 background: var(--card);
857 border: 1px solid var(--line);
858 border-radius: var(--radius-small);
859 padding: 8px 12px;
860 color: var(--ink);
861 user-select: all;
862}
863
864/* ── tabs: the one row that never goes away ─────────────────────────────── */
865
866.tabs {
867 display: flex;
868 gap: 6px;
869 margin: 16px 0;
870 padding: 4px;
871 background: var(--raised);
872 border: 1px solid var(--line);
873 border-radius: 999px;
874 width: fit-content;
875 max-width: 100%;
876 overflow-x: auto;
877}
878
879.tab {
880 padding: 7px 16px;
881 border-radius: 999px;
882 color: var(--muted);
883 font-size: 14px;
884 font-weight: 550;
885 white-space: nowrap;
886}
887.tab:hover { color: var(--ink); text-decoration: none; }
888.tab.on { background: var(--card); color: var(--ink); box-shadow: var(--shadow); }
889
890/* ── toolbar and the branch menu ────────────────────────────────────────── */
891
892.toolbar {
893 display: flex;
894 align-items: center;
895 gap: 12px;
896 margin: 0 0 12px;
897 flex-wrap: wrap;
898}
899
900.spacer { flex: 1; }
901
902.menu { position: relative; }
903.menu > summary {
904 list-style: none;
905 cursor: pointer;
906 font-size: 13.5px;
907 color: var(--ink);
908 background: var(--card);
909 border: 1px solid var(--line);
910 border-radius: 999px;
911 padding: 7px 14px;
912 user-select: none;
913}
914.menu > summary::-webkit-details-marker { display: none; }
915.menu > summary .menu-label { color: var(--muted); margin-right: 6px; }
916.menu[open] > summary { border-color: var(--accent); }
917
918.menu-list {
919 position: absolute;
920 top: calc(100% + 6px);
921 left: 0;
922 min-width: 220px;
923 max-height: 320px;
924 overflow-y: auto;
925 background: var(--card);
926 border: 1px solid var(--line);
927 border-radius: var(--radius);
928 box-shadow: var(--shadow);
929 padding: 6px;
930 z-index: 20;
931}
932.menu-list a {
933 display: block;
934 padding: 8px 12px;
935 border-radius: var(--radius-small);
936 color: var(--ink);
937 font-size: 14px;
938}
939.menu-list a:hover { background: var(--raised); text-decoration: none; }
940.menu-list a.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
941.menu-list a.menu-all { color: var(--link); border-top: 1px solid var(--line); border-radius: 0; margin-top: 6px; }
942
943/* ── the latest-change strip ────────────────────────────────────────────── */
944
945.pulse {
946 display: flex;
947 align-items: center;
948 gap: 12px;
949 padding: 12px 16px;
950 margin: 0 0 12px;
951 background: var(--card);
952 border: 1px solid var(--line);
953 border-radius: var(--radius);
954 color: var(--ink);
955}
956.pulse:hover { text-decoration: none; border-color: var(--accent); }
957.pulse-subject {
958 font-weight: 550;
959 overflow: hidden;
960 text-overflow: ellipsis;
961 white-space: nowrap;
962}
963.pulse-meta { color: var(--muted); font-size: 13px; white-space: nowrap; margin-left: auto; }
964
965/* ── panels and listings ────────────────────────────────────────────────── */
966
967.panel {
968 border: 1px solid var(--line);
969 border-radius: var(--radius);
970 overflow: hidden;
971 background: var(--card);
972 box-shadow: var(--shadow);
973}
974
975.panel + .panel, .toolbar + .panel { margin-top: 16px; }
976
977.panel-head {
978 padding: 11px 16px;
979 background: var(--raised);
980 border-bottom: 1px solid var(--line);
981 font-size: 13.5px;
982 color: var(--muted);
983 display: flex;
984 align-items: center;
985 gap: 10px;
986 flex-wrap: wrap;
987}
988.panel-head .name { color: var(--ink); font-weight: 600; }
989
990table.listing { width: 100%; border-collapse: collapse; }
991
992table.listing td {
993 padding: 11px 16px;
994 border-top: 1px solid var(--line);
995 vertical-align: middle;
996}
997table.listing tr:first-child td { border-top: none; }
998table.listing tr:hover td { background: var(--raised); }
999
1000td.name { width: 100%; }
1001td.name a { color: var(--ink); font-weight: 500; }
1002td.name a:hover { color: var(--link); }
1003
1004td.size, td.when {
1005 color: var(--muted);
1006 font-size: 12.5px;
1007 text-align: right;
1008 white-space: nowrap;
1009 font-variant-numeric: tabular-nums;
1010}
1011
1012.chip {
1013 display: inline-block;
1014 min-width: 34px;
1015 margin-right: 10px;
1016 padding: 2px 6px;
1017 border-radius: 6px;
1018 background: var(--raised);
1019 border: 1px solid var(--line);
1020 color: var(--muted);
1021 font-family: var(--mono);
1022 font-size: 10.5px;
1023 font-weight: 600;
1024 text-align: center;
1025 letter-spacing: 0.04em;
1026}
1027.chip.dir { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
1028
1029/* ── documents: a README rendered as a page ─────────────────────────────── */
1030
1031.doc-source { font-size: 12.5px; color: var(--muted); margin: 0 0 10px; }
1032.doc-source a { color: var(--muted); }
1033.doc-source a:hover { color: var(--link); }
1034
1035.doc {
1036 background: var(--card);
1037 border: 1px solid var(--line);
1038 border-radius: var(--radius);
1039 box-shadow: var(--shadow);
1040 padding: 36px clamp(20px, 6vw, 56px);
1041 font-size: 16.5px;
1042 line-height: 1.7;
1043 overflow-wrap: break-word;
1044}
1045.doc > :first-child, .md-details-body > :first-child { margin-top: 0; }
1046.doc > :last-child, .md-details-body > :last-child { margin-bottom: 0; }
1047
1048.doc h1, .doc h2, .doc h3 { letter-spacing: -0.02em; line-height: 1.25; margin: 1.5em 0 0.5em; }
1049.doc h1 { font-size: 30px; }
1050.doc h2 { font-size: 22px; border-bottom: 1px solid var(--line); padding-bottom: 8px; }
1051.doc h3 { font-size: 18px; }
1052.doc p, .doc ul, .doc ol { margin: 0 0 1em; }
1053.doc li { margin-bottom: 0.3em; }
1054.doc img { max-width: 100%; border-radius: var(--radius-small); }
1055.doc hr { border: none; border-top: 1px solid var(--line); margin: 2em 0; }
1056.doc blockquote {
1057 margin: 0 0 1em;
1058 padding: 4px 18px;
1059 border-left: 3px solid var(--accent);
1060 color: var(--muted);
1061 background: var(--raised);
1062 border-radius: 0 var(--radius-small) var(--radius-small) 0;
1063}
1064.doc code {
1065 font-family: var(--mono);
1066 font-size: 0.85em;
1067 background: var(--raised);
1068 padding: 2px 6px;
1069 border-radius: 6px;
1070}
1071.doc pre {
1072 background: var(--raised);
1073 border: 1px solid var(--line);
1074 border-radius: var(--radius-small);
1075 padding: 14px 16px;
1076 overflow-x: auto;
1077}
1078.doc pre code { background: none; padding: 0; font-size: 13px; }
1079.doc table { border-collapse: collapse; margin-bottom: 1em; display: block; overflow-x: auto; }
1080.doc th, .doc td { border: 1px solid var(--line); padding: 7px 13px; }
1081.doc th { background: var(--raised); }
1082
1083.doc-more {
1084 display: flex;
1085 gap: 12px;
1086 flex-wrap: wrap;
1087 margin-top: 18px;
1088}
1089.doc-more a {
1090 flex: 1;
1091 min-width: 200px;
1092 padding: 14px 18px;
1093 background: var(--card);
1094 border: 1px solid var(--line);
1095 border-radius: var(--radius);
1096 color: var(--ink);
1097 font-weight: 550;
1098}
1099.doc-more a:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); }
1100
1101/* a README shown under a file listing rather than as the page itself */
1102.panel .doc { border: none; border-radius: 0; box-shadow: none; }
1103
1104/* ── the tag dialect: the site design system ────────────────────────────── */
1105
1106/* The tone palette. Six moods, each a ground, an ink that reads on it, a pop
1107 for links, and an ink that reads on the pop — in both colour schemes.
1108 Attributes select a class from this fixed set; a document can choose a
1109 tone, never invent one. */
1110
1111.tone-sun { --tone-bg: #ffedc2; --tone-ink: #5c3d00; --tone-pop: #9a5b00; --tone-pop-ink: #ffffff; }
1112.tone-mint { --tone-bg: #d3f4e0; --tone-ink: #0a4d2f; --tone-pop: #0f7a49; --tone-pop-ink: #ffffff; }
1113.tone-sky { --tone-bg: #d8e9ff; --tone-ink: #163e80; --tone-pop: #2a63c4; --tone-pop-ink: #ffffff; }
1114.tone-rose { --tone-bg: #ffdfe9; --tone-ink: #801d42; --tone-pop: #c23a6f; --tone-pop-ink: #ffffff; }
1115.tone-grape { --tone-bg: #e6dcff; --tone-ink: #3d2687; --tone-pop: #6c53e6; --tone-pop-ink: #ffffff; }
1116.tone-ink { --tone-bg: #14171f; --tone-ink: #f2f4fa; --tone-pop: #b3a1ff; --tone-pop-ink: #171040; }
1117
1118@media (prefers-color-scheme: dark) {
1119 .tone-sun { --tone-bg: #33270e; --tone-ink: #ffd98a; --tone-pop: #ffbe4d; --tone-pop-ink: #2b1f06; }
1120 .tone-mint { --tone-bg: #0e2f20; --tone-ink: #8ce8ba; --tone-pop: #4ecf92; --tone-pop-ink: #06281a; }
1121 .tone-sky { --tone-bg: #12253f; --tone-ink: #a9c9ff; --tone-pop: #82aaff; --tone-pop-ink: #0a1830; }
1122 .tone-rose { --tone-bg: #391423; --tone-ink: #ffa9c8; --tone-pop: #ff7fae; --tone-pop-ink: #2e0e1c; }
1123 .tone-grape { --tone-bg: #241b45; --tone-ink: #c9b8ff; --tone-pop: #a08dff; --tone-pop-ink: #170f38; }
1124 .tone-ink { --tone-bg: #05070c; --tone-ink: #eef1f8; --tone-pop: #a08dff; --tone-pop-ink: #170f38; }
1125}
1126
1127.md-toned { background: var(--tone-bg); color: var(--tone-ink); }
1128.md-toned a { color: var(--tone-pop); }
1129.tone-ink a { color: var(--tone-pop); }
1130.md-toned code { background: rgba(127, 127, 127, 0.18); color: inherit; }
1131.md-toned blockquote { color: inherit; background: rgba(127, 127, 127, 0.12); border-color: var(--tone-pop); }
1132.md-toned h1, .md-toned h2, .md-toned h3 { color: inherit; }
1133
1134/* ── site mode: a README the width of the screen ────────────────────────── */
1135
1136/* The slim strip that replaces the repository header on the front page: the
1137 website starts at the hero, not after a stack of furniture. */
1138.site-head {
1139 display: flex;
1140 align-items: center;
1141 gap: 12px;
1142 flex-wrap: wrap;
1143 padding: 10px 0;
1144}
1145.site-id { font-size: 15px; font-weight: 650; display: flex; align-items: center; gap: 8px; }
1146.site-id .repo-owner { font-weight: 500; }
1147.site-head .tabs { margin: 0 0 0 auto; }
1148
1149/* What moved out of the top: description, clone, source — now a quiet strip
1150 after the document, where a reader who wants the machinery can find it. */
1151.site-about {
1152 margin-top: 28px;
1153 padding: 18px 20px;
1154 background: var(--card);
1155 border: 1px solid var(--line);
1156 border-radius: var(--radius);
1157 display: flex;
1158 flex-direction: column;
1159 gap: 10px;
1160 font-size: 14px;
1161 color: var(--muted);
1162}
1163.site-about .clone { margin: 0; }
1164
1165/* Text keeps a readable measure; painted sections reach the edges; wide
1166 furniture sits between. Every width is a min() against the viewport, so
1167 phones need no second layout. */
1168
1169.doc.doc-site {
1170 border: none;
1171 background: none;
1172 box-shadow: none;
1173 border-radius: 0;
1174 padding: 0;
1175 overflow: hidden;
1176}
1177.doc-site > * {
1178 width: min(76ch, calc(100% - 48px));
1179 margin-left: auto;
1180 margin-right: auto;
1181}
1182.doc-site > .md-hero, .doc-site > .md-band { width: 100%; }
1183.doc-site > .md-cards { width: min(1140px, calc(100% - 48px)); }
1184
1185/* Rhythm: painted blocks own their space through padding, not margins, so
1186 paint meets paint with no stripe of page background between. Plain prose
1187 between sections gets one consistent breath. */
1188.doc-site > .md-band, .doc-site > .md-hero { margin: 0; }
1189.doc-site > .md-band + *:not(.md-band):not(.md-hero),
1190.doc-site > .md-hero + *:not(.md-band):not(.md-hero) { margin-top: clamp(36px, 6vw, 64px); }
1191.doc-site > *:not(.md-band):not(.md-hero) + .md-band { margin-top: clamp(36px, 6vw, 64px); }
1192.doc-site > :last-child:not(.md-band):not(.md-hero) { margin-bottom: clamp(28px, 4vw, 48px); }
1193
1194.doc-site h2 { border-bottom: none; padding-bottom: 0; }
1195
1196/* ── bands ──────────────────────────────────────────────────────────────── */
1197
1198.md-band { padding: clamp(52px, 8vw, 104px) 0; }
1199.md-band > * {
1200 width: min(76ch, calc(100% - 48px));
1201 margin-left: auto;
1202 margin-right: auto;
1203}
1204.md-band > .md-cards { width: min(1140px, calc(100% - 48px)); }
1205.md-band > :first-child { margin-top: 0; }
1206.md-band > :last-child { margin-bottom: 0; }
1207.md-band h2 { font-size: clamp(24px, 3.4vw, 36px); border-bottom: none; padding-bottom: 0; margin: 0 auto 0.6em; letter-spacing: -0.025em; }
1208
1209/* Inside the boxed README (tree page, blob view) a band is a soft inset
1210 stripe rather than a bleed — the card supplies the edges. */
1211.panel .md-band { border-radius: var(--radius-small); padding: 24px 26px; }
1212.panel .md-band > *, .panel .md-band > .md-cards { width: auto; }
1213
1214/* ── authored visuals ──────────────────────────────────────────────────── */
1215
1216/* Images are evidence, not decoration. A visual gets one generous frame and
1217 no simulated browser chrome; its own composition carries the moment. */
1218.doc-site > .md-visual { width: min(1240px, calc(100% - 48px)); }
1219.md-visual { margin-top: clamp(34px, 6vw, 72px); margin-bottom: clamp(34px, 6vw, 72px); }
1220.md-visual > p { margin: 0; }
1221.md-visual img {
1222 display: block;
1223 width: 100%;
1224 max-width: none;
1225 border-radius: clamp(12px, 2vw, 24px);
1226 border: 1px solid color-mix(in srgb, var(--ink) 12%, transparent);
1227 box-shadow: 0 24px 80px -42px color-mix(in srgb, var(--ink) 55%, transparent);
1228}
1229.md-visual + * { margin-top: 0; }
1230
1231/* ── routes ────────────────────────────────────────────────────────────── */
1232
1233/* A three-step product story is easier to scan as a route than as three cards:
1234 large numbers establish order, a fine line establishes movement. */
1235.md-steps { width: min(1140px, calc(100% - 48px)); margin-top: clamp(40px, 7vw, 80px); margin-bottom: clamp(40px, 7vw, 80px); }
1236.md-steps ol {
1237 list-style: none;
1238 counter-reset: route;
1239 display: grid;
1240 grid-template-columns: repeat(3, 1fr);
1241 gap: 0;
1242 padding: 0;
1243 margin: 0;
1244}
1245.md-steps li {
1246 counter-increment: route;
1247 position: relative;
1248 min-height: 170px;
1249 padding: 58px 26px 22px 0;
1250 border-top: 1px solid var(--line);
1251 font-size: 17px;
1252 line-height: 1.5;
1253}
1254.md-steps li + li { padding-left: 26px; }
1255.md-steps li + li::after {
1256 content: "";
1257 position: absolute;
1258 top: -1px;
1259 left: 0;
1260 height: 1px;
1261 width: 1px;
1262 background: var(--accent);
1263}
1264.md-steps li::before {
1265 content: "0" counter(route);
1266 position: absolute;
1267 top: 15px;
1268 left: 0;
1269 color: var(--accent);
1270 font-size: 13px;
1271 font-weight: 750;
1272 letter-spacing: 0.08em;
1273}
1274.md-steps li p { margin: 0; }
1275
1276/* ── display type ───────────────────────────────────────────────────────── */
1277
1278.md-big {
1279 font-size: clamp(30px, 5.5vw, 72px);
1280 font-weight: 800;
1281 line-height: 1.05;
1282 letter-spacing: -0.035em;
1283 text-wrap: balance;
1284}
1285.md-big p { margin: 0 0 0.35em; }
1286.md-big > :last-child { margin-bottom: 0; }
1287.md-big.md-toned { background: none; color: var(--tone-ink); }
1288.md-band .md-big { margin-top: 0.2em; }
1289
1290/* ── hero ───────────────────────────────────────────────────────────────── */
1291
1292.md-hero {
1293 text-align: center;
1294 padding: clamp(40px, 7vw, 84px) 24px;
1295}
1296.md-hero.md-toned { padding: clamp(72px, 11vw, 148px) 24px clamp(64px, 10vw, 132px); }
1297.md-hero.md-left { text-align: left; }
1298.md-hero.md-left p { margin-left: 0; }
1299.md-hero h1 {
1300 font-size: clamp(38px, 7.5vw, 76px);
1301 font-weight: 800;
1302 margin: 0 0 0.3em;
1303 border: none;
1304 letter-spacing: -0.035em;
1305 line-height: 1.03;
1306 text-wrap: balance;
1307}
1308.md-hero p {
1309 font-size: clamp(17px, 2.2vw, 21px);
1310 line-height: 1.55;
1311 max-width: 52ch;
1312 margin: 0 auto 1.1em;
1313}
1314.md-hero p { color: inherit; }
1315.md-hero:not(.md-toned) p { color: var(--muted); }
1316
1317/* Buttons draw from the surface they sit on: outline from the current ink,
1318 fill from the tone's pop — legible on every tone by construction. */
1319.md-hero a {
1320 display: inline-block;
1321 margin: 6px 8px 0 0;
1322 padding: 13px 28px;
1323 border-radius: 999px;
1324 border: 2px solid currentColor;
1325 color: inherit;
1326 font-weight: 700;
1327 font-size: 16px;
1328 line-height: 1.2;
1329}
1330.md-hero a:hover { text-decoration: none; opacity: 0.85; }
1331.md-hero p:last-of-type a:first-child {
1332 background: var(--tone-pop, var(--accent));
1333 border-color: transparent;
1334 color: var(--tone-pop-ink, #ffffff);
1335}
1336.md-hero.md-left p:last-of-type { margin-left: 0; }
1337
1338/* ── card grids ─────────────────────────────────────────────────────────── */
1339
1340/* Compound selectors, so these out-rank the base auto-fit grid wherever the
1341 rules sit in the sheet. */
1342.md-cards.md-cols-2 { grid-template-columns: repeat(2, 1fr); }
1343.md-cards.md-cols-3 { grid-template-columns: repeat(3, 1fr); }
1344.md-cards.md-cols-4 { grid-template-columns: repeat(4, 1fr); }
1345@media (max-width: 900px) {
1346 .md-cards.md-cols-3, .md-cards.md-cols-4 { grid-template-columns: repeat(2, 1fr); }
1347}
1348@media (max-width: 580px) {
1349 .md-cards.md-cols-2, .md-cards.md-cols-3, .md-cards.md-cols-4 { grid-template-columns: 1fr; }
1350 .md-steps { width: calc(100% - 32px); }
1351 .md-steps ol { grid-template-columns: 1fr; }
1352 .md-steps li, .md-steps li + li { min-height: auto; padding: 54px 0 26px; }
1353}
1354
1355/* A toned card is its own surface; an untoned card on a painted band borrows
1356 a translucent one, so it reads on every tone without knowing which. */
1357.md-card.md-toned { border-color: transparent; }
1358.md-band .md-card:not(.md-toned) {
1359 background: rgba(127, 127, 127, 0.14);
1360 border-color: transparent;
1361 color: inherit;
1362}
1363
1364
1365.md-callout {
1366 border-radius: var(--radius-small);
1367 padding: 14px 18px;
1368 margin: 0 0 1em;
1369 background: var(--note-bg);
1370 border-left: 3px solid var(--note-ink);
1371}
1372.md-callout > :last-child { margin-bottom: 0; }
1373.md-callout-title { font-weight: 650; margin: 0 0 4px; }
1374.md-note { background: var(--note-bg); border-color: var(--note-ink); }
1375.md-note .md-callout-title { color: var(--note-ink); }
1376.md-tip { background: var(--ok-bg); border-color: var(--ok-ink); }
1377.md-tip .md-callout-title { color: var(--ok-ink); }
1378.md-warn { background: var(--warn-bg); border-color: var(--warn-ink); }
1379.md-warn .md-callout-title { color: var(--warn-ink); }
1380.md-danger { background: var(--bad-bg); border-color: var(--bad-ink); }
1381.md-danger .md-callout-title { color: var(--bad-ink); }
1382
1383.md-cards {
1384 display: grid;
1385 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1386 gap: 14px;
1387 margin: 0 0 1em;
1388}
1389.md-card {
1390 border: 1px solid var(--line);
1391 border-radius: var(--radius);
1392 padding: 16px 18px;
1393 background: var(--raised);
1394}
1395.md-card > :last-child { margin-bottom: 0; }
1396.md-card-title { font-weight: 650; margin: 0 0 6px; }
1397
1398.md-details {
1399 border: 1px solid var(--line);
1400 border-radius: var(--radius-small);
1401 margin: 0 0 1em;
1402 background: var(--raised);
1403}
1404.md-details > summary {
1405 cursor: pointer;
1406 padding: 12px 16px;
1407 font-weight: 600;
1408 user-select: none;
1409}
1410.md-details[open] > summary { border-bottom: 1px solid var(--line); }
1411.md-details-body { padding: 14px 16px; }
1412
1413/* ── code ───────────────────────────────────────────────────────────────── */
1414
1415.code { overflow-x: auto; }
1416
1417table.code-table {
1418 border-collapse: collapse;
1419 font-family: var(--mono);
1420 font-size: 12.5px;
1421 line-height: 1.55;
1422 width: 100%;
1423}
1424
1425table.code-table td.ln {
1426 width: 1%;
1427 min-width: 46px;
1428 padding: 0 12px 0 14px;
1429 text-align: right;
1430 color: var(--muted);
1431 user-select: none;
1432 vertical-align: top;
1433 border-right: 1px solid var(--line);
1434 font-variant-numeric: tabular-nums;
1435}
1436
1437table.code-table td.ln a { color: inherit; }
1438table.code-table td.src { padding: 0 14px; white-space: pre; vertical-align: top; }
1439table.code-table tr:target td { background: var(--warn-bg); }
1440table.code-table tr:target td.ln { color: var(--warn-ink); }
1441
1442/* ── history: a feed, not a table ───────────────────────────────────────── */
1443
1444.feed { display: flex; flex-direction: column; }
1445
1446.feed-item {
1447 display: flex;
1448 align-items: center;
1449 gap: 12px;
1450 padding: 12px 16px;
1451 border-top: 1px solid var(--line);
1452 color: var(--ink);
1453}
1454.feed-item:first-child { border-top: none; }
1455.feed-item:hover { background: var(--raised); text-decoration: none; }
1456
1457.avatar {
1458 flex: none;
1459 width: 36px;
1460 height: 36px;
1461 border-radius: 50%;
1462 display: inline-flex;
1463 align-items: center;
1464 justify-content: center;
1465 font-size: 13px;
1466 font-weight: 700;
1467 color: #ffffff;
1468}
1469.av0 { background: #6c53e6; }
1470.av1 { background: #1f9d8b; }
1471.av2 { background: #d76b2f; }
1472.av3 { background: #3b76d6; }
1473.av4 { background: #b84a8f; }
1474.av5 { background: #5a8f2e; }
1475.av6 { background: #c0334b; }
1476.av7 { background: #7b6ac2; }
1477
1478.feed-main { min-width: 0; display: flex; flex-direction: column; }
1479.feed-subject {
1480 font-weight: 550;
1481 overflow: hidden;
1482 text-overflow: ellipsis;
1483 white-space: nowrap;
1484}
1485.feed-meta { color: var(--muted); font-size: 12.5px; }
1486
1487code.sha {
1488 font-family: var(--mono);
1489 font-size: 12px;
1490 color: var(--muted);
1491 background: var(--raised);
1492 border: 1px solid var(--line);
1493 border-radius: 6px;
1494 padding: 1px 6px;
1495 margin-left: auto;
1496 flex: none;
1497}
1498
1499pre.message {
1500 font-family: var(--mono);
1501 font-size: 13px;
1502 white-space: pre-wrap;
1503 word-break: break-word;
1504 margin: 0;
1505 padding: 16px 18px;
1506}
1507
1508.pager { display: flex; justify-content: space-between; margin-top: 14px; }
1509.pager a {
1510 padding: 9px 18px;
1511 background: var(--card);
1512 border: 1px solid var(--line);
1513 border-radius: 999px;
1514 font-weight: 550;
1515 font-size: 14px;
1516}
1517.pager a:hover { text-decoration: none; border-color: var(--accent); }
1518.pager .spacer { flex: 1; }
1519
1520.delta {
1521 display: inline-block;
1522 min-width: 74px;
1523 margin-right: 10px;
1524 padding: 2px 8px;
1525 border-radius: 6px;
1526 font-size: 11.5px;
1527 font-weight: 650;
1528 text-align: center;
1529 background: var(--raised);
1530 color: var(--muted);
1531}
1532.delta-add { background: var(--ok-bg); color: var(--ok-ink); }
1533.delta-del { background: var(--bad-bg); color: var(--bad-ink); }
1534.delta-mod { background: var(--note-bg); color: var(--note-ink); }
1535
1536/* ── notices ────────────────────────────────────────────────────────────── */
1537
1538.empty, .notice {
1539 padding: 40px 24px;
1540 text-align: center;
1541 color: var(--muted);
1542}
1543
1544.notice strong { color: var(--ink); display: block; font-size: 18px; margin-bottom: 6px; }
1545.welcome { margin-top: 48px; }
1546
1547.foot {
1548 margin: 48px 0 30px;
1549 padding-top: 18px;
1550 border-top: 1px solid var(--line);
1551 color: var(--muted);
1552 font-size: 12.5px;
1553}
1554
1555@media (max-width: 640px) {
1556 .wrap { padding: 0 16px; }
1557 td.when { display: none; }
1558 .repo-title { font-size: 22px; }
1559 .doc { padding: 24px 18px; font-size: 16px; }
1560 .pulse-meta { display: none; }
1561}
1562"#;
1563
1564#[cfg(test)]
1565mod tests {
1566 use super::*;
1567
1568 fn markdown(source: &str) -> String {
1569 render_markdown(source, None)
1570 }
1571
1572 fn base() -> LinkBase {
1573 LinkBase {
1574 repo: "/alice/site".to_string(),
1575 reference: "main".to_string(),
1576 dir: "docs".to_string(),
1577 }
1578 }
1579
1580 #[test]
1581 fn escaping_covers_both_body_and_attribute_context() {
1582 assert_eq!(escape("<script>"), "&lt;script&gt;");
1583 assert_eq!(escape("a&b"), "a&amp;b");
1584 // Quotes matter: a repository description lands inside an attribute, and a
1585 // bare quote there ends the attribute and starts writing markup.
1586 assert_eq!(escape("\"onload=x"), "&quot;onload=x");
1587 assert_eq!(escape("'onload=x"), "&#39;onload=x");
1588 }
1589
1590 #[test]
1591 fn markdown_drops_embedded_html_rather_than_rendering_it() {
1592 // Anyone who can push can write a README. On a public repository that is a
1593 // stranger, so raw HTML in markdown is an attacker-supplied script tag.
1594 let out = markdown("# hi\n\n<script>alert(1)</script>\n\n<img src=x onerror=y>");
1595 assert!(!out.contains("<script"), "{out}");
1596 assert!(!out.contains("onerror"), "{out}");
1597 assert!(out.contains(">hi</h1>"), "{out}");
1598 }
1599
1600 #[test]
1601 fn a_markdown_link_cannot_carry_an_executable_scheme() {
1602 // A README is attacker-controlled on a public repository, and pulldown-cmark
1603 // emits destinations verbatim, so this is the one place markdown can still
1604 // execute after raw HTML has been dropped.
1605 for hostile in [
1606 "[x](javascript:alert(1))",
1607 "[x](JaVaScRiPt:alert(1))",
1608 "[x](java\tscript:alert(1))",
1609 "[x](data:text/html;base64,PHNjcmlwdD4=)",
1610 "[x](vbscript:msgbox)",
1611 "![x](javascript:alert(1))",
1612 ] {
1613 let out = markdown(hostile);
1614 assert!(
1615 !out.to_lowercase().contains("javascript:")
1616 && !out.to_lowercase().contains("vbscript:")
1617 && !out.to_lowercase().contains("data:text/html"),
1618 "{hostile} rendered as {out}"
1619 );
1620 }
1621 }
1622
1623 #[test]
1624 fn hostile_schemes_stay_blocked_when_links_are_rewritten() {
1625 // The rewriting path must not reopen the hole the plain path closed.
1626 let out = readme("[x](javascript:alert(1))\n", &base());
1627 assert!(!out.to_lowercase().contains("javascript:"), "{out}");
1628 assert!(out.contains("#blocked"), "{out}");
1629 }
1630
1631 #[test]
1632 fn ordinary_links_are_left_alone() {
1633 // Over-blocking would quietly break every README on the host.
1634 for fine in [
1635 "[x](https://example.com/a?b=c#d)",
1636 "[x](http://example.com)",
1637 "[x](docs/guide.md)",
1638 "[x](./relative)",
1639 "[x](#anchor)",
1640 "[x](mailto:a@b.c)",
1641 ] {
1642 let out = markdown(fine);
1643 assert!(!out.contains("#blocked"), "{fine} was blocked: {out}");
1644 }
1645 }
1646
1647 #[test]
1648 fn a_relative_link_resolves_against_the_documents_directory() {
1649 // `[guide](guide.md)` inside docs/README.md must open docs/guide.md as a
1650 // rendered page — otherwise a README can never link to its own folder.
1651 let out = readme("[guide](guide.md)", &base());
1652 assert!(out.contains("/alice/site/blob/main/docs/guide.md"), "{out}");
1653
1654 // An image gets the narrow image route: a blob page is HTML, while raw
1655 // bytes are attachments and would make the browser download the visual.
1656 let out = readme("![shot](img/shot.png)", &base());
1657 assert!(
1658 out.contains("/alice/site/media/main/docs/img/shot.png"),
1659 "{out}"
1660 );
1661
1662 // A directory link opens the listing.
1663 let out = readme("[examples](examples/)", &base());
1664 assert!(out.contains("/alice/site/tree/main/docs/examples"), "{out}");
1665 }
1666
1667 #[test]
1668 fn a_relative_link_cannot_climb_out_of_the_repository() {
1669 // `..` is a repository path here, not a filesystem one; the worst it may
1670 // reach is the repository root.
1671 let out = readme("[up](../../../../etc/passwd)", &base());
1672 assert!(out.contains("/alice/site/blob/main/etc/passwd"), "{out}");
1673 assert!(!out.contains("../"), "{out}");
1674 }
1675
1676 #[test]
1677 fn anchors_and_absolute_urls_survive_rewriting() {
1678 let out = readme("[a](#section) [b](https://example.com/x)", &base());
1679 assert!(out.contains("href=\"#section\""), "{out}");
1680 assert!(out.contains("https://example.com/x"), "{out}");
1681 }
1682
1683 #[test]
1684 fn a_relative_links_query_is_not_made_part_of_its_filename() {
1685 // A page can link its reader to its source with `?plain=1`; encoding the
1686 // question mark as a filename used to turn that useful link into a 404.
1687 let out = readme("[source](README.md?plain=1#L2)", &base());
1688 assert!(
1689 out.contains("/alice/site/blob/main/docs/README.md?plain=1#L2"),
1690 "{out}"
1691 );
1692 assert!(!out.contains("README.md%3Fplain"), "{out}");
1693 }
1694
1695 #[test]
1696 fn headings_get_stable_anchor_ids() {
1697 // The front page of a repository is a document; a document needs working
1698 // in-page links. Duplicate headings must not produce duplicate ids.
1699 let out = markdown("## Getting started\n\n## Getting started\n");
1700 assert!(out.contains("id=\"getting-started\""), "{out}");
1701 assert!(out.contains("id=\"getting-started-2\""), "{out}");
1702 }
1703
1704 #[test]
1705 fn markdown_still_escapes_text_that_looks_like_markup() {
1706 let out = markdown("plain `<b>` and <not-a-tag>");
1707 assert!(!out.contains("<b>"), "{out}");
1708 }
1709
1710 #[test]
1711 fn a_url_segment_cannot_carry_a_query_or_fragment() {
1712 // A file legitimately named `a?b#c` would otherwise produce a link that
1713 // silently addresses a different path.
1714 assert_eq!(url_escape("a?b#c"), "a%3Fb%23c");
1715 assert_eq!(url_escape("dir/name.txt"), "dir/name.txt");
1716 assert_eq!(url_escape("a b"), "a%20b");
1717 }
1718
1719 #[test]
1720 fn the_stylesheet_url_changes_when_the_build_does() {
1721 // It is served immutable for a year, so a deploy that reused the URL would
1722 // leave every returning visitor on the previous stylesheet.
1723 assert!(style_url().contains(brand::COMMIT));
1724 }
1725
1726 #[test]
1727 fn an_overview_can_publish_a_complete_escaped_social_card() {
1728 let title = format!("{} — Git hosting", brand::NAME);
1729 let canonical = "https://example.com/account/project";
1730 let image = "https://example.com/account/project/media/main/assets/og-image.png";
1731 let document = document(&Page {
1732 title: title.clone(),
1733 heading: String::new(),
1734 body: String::new(),
1735 wide: true,
1736 seo: Some(Seo {
1737 description: "Git hosting for agents & people".into(),
1738 canonical: canonical.into(),
1739 image: Some(image.into()),
1740 image_alt: Some(title.clone()),
1741 }),
1742 });
1743
1744 for tag in [
1745 format!("<link rel=\"canonical\" href=\"{canonical}\">"),
1746 format!("<meta property=\"og:title\" content=\"{title}\">"),
1747 "<meta property=\"og:image:width\" content=\"1200\">".into(),
1748 "<meta property=\"og:image:height\" content=\"630\">".into(),
1749 "<meta name=\"twitter:card\" content=\"summary_large_image\">".into(),
1750 format!("<meta name=\"twitter:image\" content=\"{image}\">"),
1751 ] {
1752 assert!(document.contains(&tag), "missing {tag} in {document}");
1753 }
1754 assert!(
1755 document.contains("Git hosting for agents &amp; people"),
1756 "{document}"
1757 );
1758 }
1759
1760 #[test]
1761 fn recognised_tags_become_wrappers_and_their_content_still_renders() {
1762 let source = "{% hero %}\n# Big title\n{% /hero %}\n\n\
1763 {% callout type=\"tip\" title=\"Hint\" %}\nBe kind.\n{% /callout %}\n";
1764 let out = readme(source, &base());
1765 assert!(out.contains("class=\"md-hero\""), "{out}");
1766 assert!(out.contains(">Big title</h1>"), "{out}");
1767 assert!(out.contains("md-callout md-tip"), "{out}");
1768 assert!(out.contains("Hint"), "{out}");
1769 assert!(out.contains("Be kind."), "{out}");
1770 }
1771
1772 #[test]
1773 fn an_unknown_tag_line_disappears_but_its_content_does_not() {
1774 // A document written for a richer engine must degrade to its text, never to
1775 // visible tag soup.
1776 let out = readme(
1777 "{% fancy-widget id=\"x\" %}\nThe words.\n{% /fancy-widget %}\n",
1778 &base(),
1779 );
1780 assert!(!out.contains("fancy-widget"), "{out}");
1781 assert!(out.contains("The words."), "{out}");
1782 }
1783
1784 #[test]
1785 fn a_tag_attribute_cannot_inject_markup() {
1786 // The title lands inside the page; a quote in it must not open an attribute.
1787 let out = readme(
1788 "{% callout title=\"<img src=x onerror=y>\" %}\nhi\n{% /callout %}\n",
1789 &base(),
1790 );
1791 // The payload must survive only as inert text, never as an element.
1792 assert!(!out.contains("<img"), "{out}");
1793 assert!(out.contains("&lt;img"), "{out}");
1794 }
1795
1796 #[test]
1797 fn a_tag_inside_a_code_fence_is_shown_not_executed() {
1798 // A README documenting the dialect has to be able to quote it.
1799 let out = readme("```\n{% hero %}\n```\n", &base());
1800 assert!(!out.contains("md-hero"), "{out}");
1801 assert!(out.contains("{% hero %}"), "{out}");
1802 }
1803
1804 #[test]
1805 fn an_unclosed_tag_is_closed_rather_than_swallowing_the_page() {
1806 let out = readme("{% callout %}\nstill here\n", &base());
1807 assert!(out.contains("still here"), "{out}");
1808 assert!(out.contains("</aside>"), "{out}");
1809 }
1810
1811 #[test]
1812 fn details_render_as_a_native_disclosure() {
1813 let out = readme(
1814 "{% details summary=\"All settings\" %}\nthe table\n{% /details %}\n",
1815 &base(),
1816 );
1817 assert!(out.contains("<details class=\"md-details\">"), "{out}");
1818 assert!(out.contains("<summary>All settings</summary>"), "{out}");
1819 assert!(out.contains("the table"), "{out}");
1820 assert!(out.contains("</details>"), "{out}");
1821 }
1822
1823 #[test]
1824 fn cards_nest_inside_a_card_grid() {
1825 let out = readme(
1826 "{% cards %}\n{% card title=\"One\" %}\nfirst\n{% /card %}\n\
1827 {% card title=\"Two\" %}\nsecond\n{% /card %}\n{% /cards %}\n",
1828 &base(),
1829 );
1830 assert!(out.contains("md-cards"), "{out}");
1831 assert_eq!(out.matches("md-card\"").count(), 2, "{out}");
1832 assert!(out.contains("One"), "{out}");
1833 assert!(out.contains("second"), "{out}");
1834 }
1835
1836 #[test]
1837 fn a_tone_is_chosen_from_the_palette_never_written_by_the_document() {
1838 // The attribute value selects a constant; user text must never reach the
1839 // class attribute, or a tone would be an HTML injection point.
1840 let out = readme("{% band tone=\"mint\" %}\nhi\n{% /band %}\n", &base());
1841 assert!(out.contains("md-band md-toned tone-mint"), "{out}");
1842
1843 let hostile = readme(
1844 "{% band tone=\"x&quot; onmouseover=&quot;alert(1)\" %}\nhi\n{% /band %}\n",
1845 &base(),
1846 );
1847 assert!(hostile.contains("class=\"md-band\""), "{hostile}");
1848 assert!(!hostile.contains("onmouseover"), "{hostile}");
1849
1850 // Unknown tone: the band still renders, unpainted.
1851 let plain = readme("{% band tone=\"chartreuse\" %}\nhi\n{% /band %}\n", &base());
1852 assert!(plain.contains("class=\"md-band\""), "{plain}");
1853 assert!(!plain.contains("tone-"), "{plain}");
1854 }
1855
1856 #[test]
1857 fn bands_bigs_and_column_counts_render_with_their_content() {
1858 let out = readme(
1859 "{% band tone=\"ink\" %}\n## Inside\n{% big tone=\"sun\" %}\nLoud.\n{% /big %}\n{% /band %}\n\
1860 {% visual %}\n![Proof](shot.png)\n{% /visual %}\n\
1861 {% steps %}\n1. First\n2. Second\n{% /steps %}\n\
1862 {% cards columns=\"3\" %}\n{% card tone=\"rose\" title=\"One\" %}\nfirst\n{% /card %}\n{% /cards %}\n",
1863 &base(),
1864 );
1865 assert!(out.contains("md-band md-toned tone-ink"), "{out}");
1866 assert!(out.contains("md-big md-toned tone-sun"), "{out}");
1867 assert!(out.contains("Loud."), "{out}");
1868 assert!(out.contains("md-cards md-cols-3"), "{out}");
1869 assert!(out.contains("md-card md-toned tone-rose"), "{out}");
1870 assert!(out.contains("<figure class=\"md-visual\">"), "{out}");
1871 assert!(out.contains("<section class=\"md-steps\">"), "{out}");
1872 assert!(
1873 out.contains("/alice/site/media/main/docs/shot.png"),
1874 "{out}"
1875 );
1876 assert!(out.contains(">Inside</h2>"), "{out}");
1877 // A column count outside the allow-list falls back to the fluid grid.
1878 let fluid = readme("{% cards columns=\"9\" %}\n{% /cards %}\n", &base());
1879 assert!(fluid.contains("class=\"md-cards\""), "{fluid}");
1880 }
1881
1882 #[test]
1883 fn a_hero_may_lean_left_but_only_left() {
1884 let out = readme("{% hero align=\"left\" %}\n# T\n{% /hero %}\n", &base());
1885 assert!(out.contains("md-hero md-left"), "{out}");
1886 let odd = readme("{% hero align=\"diagonal\" %}\n# T\n{% /hero %}\n", &base());
1887 assert!(odd.contains("class=\"md-hero\""), "{odd}");
1888 }
1889
1890 #[test]
1891 fn slugs_read_like_the_heading() {
1892 assert_eq!(slugify("Getting started"), "getting-started");
1893 assert_eq!(slugify("What's new?"), "what-s-new");
1894 assert_eq!(slugify(" -- "), "");
1895 assert_eq!(slugify("Étape 1"), "étape-1");
1896 }
1897}