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.rs46.1 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 (path_part, fragment) = match url.split_once('#') {
320 Some((p, f)) => (p, Some(f)),
321 None => (url, None),
322 };
323
324 let joined = join_relative(&base.dir, path_part);
325 let verb = if image {
326 "raw"
327 } else if joined.is_empty() || path_part.ends_with('/') {
328 // `.` or `docs/` name a directory, and a directory is a listing.
329 "tree"
330 } else {
331 "blob"
332 };
333
334 let mut out = format!(
335 "{}/{}/{}/{}",
336 base.repo,
337 verb,
338 url_escape(&base.reference),
339 url_escape(&joined)
340 );
341 if let Some(f) = fragment {
342 out.push('#');
343 out.push_str(f);
344 }
345 out
346}
347
348/// Join a relative path onto a directory, clamped at the repository root.
349///
350/// `..` pops a component and can never climb above the root — the result is a
351/// repository path, not a filesystem one, and the viewer resolves it through git,
352/// so the worst a hostile path can reach is a 404.
353fn join_relative(dir: &str, relative: &str) -> String {
354 let mut parts: Vec<&str> = dir.split('/').filter(|p| !p.is_empty()).collect();
355 for segment in relative.split('/') {
356 match segment {
357 "" | "." => {}
358 ".." => {
359 parts.pop();
360 }
361 other => parts.push(other),
362 }
363 }
364 parts.join("/")
365}
366
367// ── the tag dialect ─────────────────────────────────────────────────────────
368
369enum Token {
370 Open {
371 name: String,
372 attrs: Vec<(String, String)>,
373 },
374 Close(String),
375 /// Syntactically a tag, semantically nothing — dropped from the output.
376 Ignored,
377}
378
379/// Parse one line as a tag, if it is one.
380///
381/// The whole line must be the tag: `{%` at column zero, `%}` at the end. Anything
382/// else is content and stays content, so a stray `{%` mid-sentence cannot eat the
383/// rest of a document.
384fn tag_token(line: &str) -> Option<Token> {
385 let trimmed = line.trim_end();
386 if !trimmed.starts_with("{%") || !trimmed.ends_with("%}") || trimmed.len() < 4 {
387 return None;
388 }
389 let inner = trimmed[2..trimmed.len() - 2].trim();
390 if inner.is_empty() {
391 return Some(Token::Ignored);
392 }
393
394 if let Some(rest) = inner.strip_prefix('/') {
395 return Some(Token::Close(rest.trim().to_ascii_lowercase()));
396 }
397
398 // Self-closing tags exist in Markdoc for void elements; this dialect has no
399 // void elements, so they are recognised and dropped rather than half-opened.
400 let (body, self_closing) = match inner.strip_suffix('/') {
401 Some(stripped) => (stripped.trim(), true),
402 None => (inner, false),
403 };
404
405 let name: String = body
406 .chars()
407 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
408 .collect();
409 if name.is_empty() {
410 return Some(Token::Ignored);
411 }
412 if self_closing {
413 return Some(Token::Ignored);
414 }
415 let attrs = parse_attrs(&body[name.len()..]);
416 Some(Token::Open {
417 name: name.to_ascii_lowercase(),
418 attrs,
419 })
420}
421
422/// `key="value"` pairs. Values run to the next quote with no escapes — the dialect
423/// is for titles and summaries, not for programs.
424fn parse_attrs(input: &str) -> Vec<(String, String)> {
425 let mut out = Vec::new();
426 let mut chars = input.chars().peekable();
427 loop {
428 while chars.next_if(|c| c.is_whitespace()).is_some() {}
429 let mut key = String::new();
430 while let Some(c) = chars.next_if(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') {
431 key.push(c);
432 }
433 if key.is_empty() {
434 break;
435 }
436 if chars.next_if_eq(&'=').is_none() || chars.next_if_eq(&'"').is_none() {
437 break;
438 }
439 let mut value = String::new();
440 loop {
441 match chars.next() {
442 Some('"') => break,
443 Some(c) => value.push(c),
444 None => return out,
445 }
446 }
447 out.push((key.to_ascii_lowercase(), value));
448 }
449 out
450}
451
452/// The HTML a recognised tag opens and closes with.
453///
454/// Every attribute value is escaped, tag names are matched against this list and
455/// nothing else, and no attribute ever becomes a URL — so the dialect adds no
456/// injection surface beyond what `escape` already guards.
457fn wrapper(name: &str, attrs: &[(String, String)]) -> Option<(String, &'static str)> {
458 let get = |key: &str| {
459 attrs
460 .iter()
461 .find(|(k, _)| k == key)
462 .map(|(_, v)| v.as_str())
463 };
464
465 match name {
466 "hero" => Some(("<section class=\"md-hero\">".to_string(), "</section>")),
467 "callout" | "note" | "tip" | "warn" | "warning" | "danger" => {
468 let kind = match name {
469 "callout" => get("type").unwrap_or("note"),
470 other => other,
471 };
472 let kind = match kind {
473 "tip" => "tip",
474 "warn" | "warning" | "caution" => "warn",
475 "danger" | "error" => "danger",
476 _ => "note",
477 };
478 let mut opening = format!("<aside class=\"md-callout md-{kind}\">");
479 if let Some(title) = get("title") {
480 opening.push_str(&format!(
481 "<p class=\"md-callout-title\">{}</p>",
482 escape(title)
483 ));
484 }
485 Some((opening, "</aside>"))
486 }
487 // <details> is the one disclosure widget that works with no script, which
488 // is what this surface has.
489 "details" => Some((
490 format!(
491 "<details class=\"md-details\"><summary>{}</summary><div class=\"md-details-body\">",
492 escape(get("summary").unwrap_or("More"))
493 ),
494 "</div></details>",
495 )),
496 "cards" => Some(("<div class=\"md-cards\">".to_string(), "</div>")),
497 "card" => {
498 let mut opening = String::from("<div class=\"md-card\">");
499 if let Some(title) = get("title") {
500 opening.push_str(&format!("<p class=\"md-card-title\">{}</p>", escape(title)));
501 }
502 Some((opening, "</div>"))
503 }
504 _ => None,
505 }
506}
507
508// ── the shell ───────────────────────────────────────────────────────────────
509
510/// URL of the stylesheet, carrying the build so a deploy cannot serve stale CSS
511/// from a cache told to keep it for a year.
512pub fn style_url() -> String {
513 format!("/_{}/{}.css", brand::NAME, brand::COMMIT)
514}
515
516pub struct Page {
517 pub title: String,
518 /// Rendered into the header bar, already escaped.
519 pub heading: String,
520 pub body: String,
521}
522
523/// Wrap a page body in the shell.
524pub fn document(page: &Page) -> String {
525 let mut out = String::with_capacity(page.body.len() + 2048);
526 out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
527 out.push_str("<meta charset=\"utf-8\">\n");
528 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
529 out.push_str(&format!("<title>{}</title>\n", escape(&page.title)));
530 // Dark and light are both first-class; the browser picks. Declaring this stops
531 // form controls and scrollbars rendering light on a dark page.
532 out.push_str("<meta name=\"color-scheme\" content=\"light dark\">\n");
533 out.push_str(&format!(
534 "<link rel=\"stylesheet\" href=\"{}\">\n",
535 style_url()
536 ));
537 out.push_str("</head>\n<body>\n");
538
539 out.push_str("<header class=\"bar\">\n<div class=\"wrap bar-inner\">\n");
540 out.push_str(&format!(
541 "<a class=\"brand\" href=\"/\">{}</a>\n",
542 escape(brand::NAME)
543 ));
544 out.push_str(&format!("<div class=\"crumbs\">{}</div>\n", page.heading));
545 out.push_str("</div>\n</header>\n");
546
547 out.push_str("<main class=\"wrap\">\n");
548 out.push_str(&page.body);
549 out.push_str("\n</main>\n");
550
551 out.push_str("<footer class=\"wrap foot\">\n");
552 out.push_str(&format!(
553 "<span>served by {} {}</span>\n",
554 escape(brand::NAME),
555 escape(brand::VERSION)
556 ));
557 out.push_str("</footer>\n");
558
559 out.push_str("</body>\n</html>\n");
560 out
561}
562
563pub const STYLE: &str = r#"
564/* Repository viewer.
565
566 No framework and no build step: the service ships as one binary, and a
567 stylesheet that needed compiling would mean a toolchain in the release path.
568
569 Written for people who have never used a code host: soft cards, plain words,
570 big touch targets, and a repository front page that reads as a document. */
571
572*, *::before, *::after { box-sizing: border-box; }
573
574:root {
575 --bg: #f6f7fb;
576 --card: #ffffff;
577 --raised: #f1f2f8;
578 --line: #e2e4ef;
579 --ink: #191d27;
580 --muted: #646c7f;
581 --link: #2f56d8;
582 --accent: #6c53e6;
583 --accent-soft: #efeaff;
584 --ok-bg: #e2f6ea;
585 --ok-ink: #157a43;
586 --warn-bg: #fdf2dc;
587 --warn-ink: #8a5a08;
588 --bad-bg: #fde8ec;
589 --bad-ink: #c0334b;
590 --note-bg: #e9f0fe;
591 --note-ink: #2d63c8;
592 --radius: 14px;
593 --radius-small: 8px;
594 --shadow: 0 1px 2px rgba(20, 24, 40, 0.05), 0 8px 24px -18px rgba(20, 24, 40, 0.25);
595 --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo,
596 Consolas, "Liberation Mono", monospace;
597 --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
598 Arial, sans-serif;
599}
600
601@media (prefers-color-scheme: dark) {
602 :root {
603 --bg: #0e1117;
604 --card: #161b24;
605 --raised: #1c222d;
606 --line: #2a313e;
607 --ink: #e8eaf1;
608 --muted: #98a1b3;
609 --link: #82aaff;
610 --accent: #a08dff;
611 --accent-soft: #262040;
612 --ok-bg: #123322;
613 --ok-ink: #5fd08a;
614 --warn-bg: #34280f;
615 --warn-ink: #e0b055;
616 --bad-bg: #3a1a22;
617 --bad-ink: #ff8ba0;
618 --note-bg: #16233c;
619 --note-ink: #8ab2ff;
620 --shadow: none;
621 }
622}
623
624html { -webkit-text-size-adjust: 100%; }
625
626body {
627 margin: 0;
628 background: var(--bg);
629 color: var(--ink);
630 font-family: var(--sans);
631 font-size: 15.5px;
632 line-height: 1.65;
633}
634
635.wrap { max-width: 1020px; margin: 0 auto; padding: 0 20px; }
636
637a { color: var(--link); text-decoration: none; }
638a:hover { text-decoration: underline; }
639
640/* ── header ─────────────────────────────────────────────────────────────── */
641
642.bar {
643 border-bottom: 1px solid var(--line);
644 background: var(--card);
645 position: sticky;
646 top: 0;
647 z-index: 10;
648}
649
650.bar-inner {
651 display: flex;
652 align-items: center;
653 gap: 14px;
654 height: 54px;
655}
656
657.brand {
658 font-weight: 700;
659 letter-spacing: -0.01em;
660 color: var(--ink);
661 font-size: 17px;
662}
663.brand:hover { text-decoration: none; color: var(--accent); }
664
665.crumbs {
666 font-size: 14px;
667 color: var(--muted);
668 overflow: hidden;
669 text-overflow: ellipsis;
670 white-space: nowrap;
671}
672.crumbs a { font-weight: 550; }
673.crumbs .sep { color: var(--line); margin: 0 3px; }
674
675/* ── repository head ────────────────────────────────────────────────────── */
676
677.repo-head { padding: 30px 0 0; }
678
679.repo-title {
680 margin: 0;
681 font-size: 27px;
682 font-weight: 700;
683 letter-spacing: -0.02em;
684 display: flex;
685 align-items: center;
686 gap: 10px;
687 flex-wrap: wrap;
688}
689.repo-owner { color: var(--muted); font-weight: 500; }
690
691.repo-desc { margin: 8px 0 0; color: var(--muted); max-width: 70ch; font-size: 16px; }
692
693.badge {
694 font-size: 11px;
695 font-weight: 650;
696 text-transform: uppercase;
697 letter-spacing: 0.07em;
698 padding: 3px 9px;
699 border-radius: 999px;
700 border: 1px solid var(--line);
701 color: var(--muted);
702 background: var(--card);
703}
704.badge.public { background: var(--ok-bg); color: var(--ok-ink); border-color: transparent; }
705
706.clone {
707 display: flex;
708 gap: 10px;
709 align-items: center;
710 flex-wrap: wrap;
711 margin-top: 16px;
712}
713.clone-label { font-size: 13px; color: var(--muted); }
714.clone code {
715 font-family: var(--mono);
716 font-size: 12.5px;
717 background: var(--card);
718 border: 1px solid var(--line);
719 border-radius: var(--radius-small);
720 padding: 8px 12px;
721 color: var(--ink);
722 user-select: all;
723}
724
725/* ── tabs: the one row that never goes away ─────────────────────────────── */
726
727.tabs {
728 display: flex;
729 gap: 6px;
730 margin: 20px 0 22px;
731 padding: 4px;
732 background: var(--raised);
733 border: 1px solid var(--line);
734 border-radius: 999px;
735 width: fit-content;
736 max-width: 100%;
737 overflow-x: auto;
738}
739
740.tab {
741 padding: 7px 16px;
742 border-radius: 999px;
743 color: var(--muted);
744 font-size: 14px;
745 font-weight: 550;
746 white-space: nowrap;
747}
748.tab:hover { color: var(--ink); text-decoration: none; }
749.tab.on { background: var(--card); color: var(--ink); box-shadow: var(--shadow); }
750
751/* ── toolbar and the branch menu ────────────────────────────────────────── */
752
753.toolbar {
754 display: flex;
755 align-items: center;
756 gap: 12px;
757 margin: 0 0 14px;
758 flex-wrap: wrap;
759}
760
761.spacer { flex: 1; }
762
763.menu { position: relative; }
764.menu > summary {
765 list-style: none;
766 cursor: pointer;
767 font-size: 13.5px;
768 color: var(--ink);
769 background: var(--card);
770 border: 1px solid var(--line);
771 border-radius: 999px;
772 padding: 7px 14px;
773 user-select: none;
774}
775.menu > summary::-webkit-details-marker { display: none; }
776.menu > summary .menu-label { color: var(--muted); margin-right: 6px; }
777.menu[open] > summary { border-color: var(--accent); }
778
779.menu-list {
780 position: absolute;
781 top: calc(100% + 6px);
782 left: 0;
783 min-width: 220px;
784 max-height: 320px;
785 overflow-y: auto;
786 background: var(--card);
787 border: 1px solid var(--line);
788 border-radius: var(--radius);
789 box-shadow: var(--shadow);
790 padding: 6px;
791 z-index: 20;
792}
793.menu-list a {
794 display: block;
795 padding: 8px 12px;
796 border-radius: var(--radius-small);
797 color: var(--ink);
798 font-size: 14px;
799}
800.menu-list a:hover { background: var(--raised); text-decoration: none; }
801.menu-list a.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
802.menu-list a.menu-all { color: var(--link); border-top: 1px solid var(--line); border-radius: 0; margin-top: 6px; }
803
804/* ── the latest-change strip ────────────────────────────────────────────── */
805
806.pulse {
807 display: flex;
808 align-items: center;
809 gap: 12px;
810 padding: 12px 16px;
811 margin: 0 0 14px;
812 background: var(--card);
813 border: 1px solid var(--line);
814 border-radius: var(--radius);
815 color: var(--ink);
816}
817.pulse:hover { text-decoration: none; border-color: var(--accent); }
818.pulse-subject {
819 font-weight: 550;
820 overflow: hidden;
821 text-overflow: ellipsis;
822 white-space: nowrap;
823}
824.pulse-meta { color: var(--muted); font-size: 13px; white-space: nowrap; margin-left: auto; }
825
826/* ── panels and listings ────────────────────────────────────────────────── */
827
828.panel {
829 border: 1px solid var(--line);
830 border-radius: var(--radius);
831 overflow: hidden;
832 background: var(--card);
833 box-shadow: var(--shadow);
834}
835
836.panel + .panel, .toolbar + .panel { margin-top: 18px; }
837
838.panel-head {
839 padding: 11px 16px;
840 background: var(--raised);
841 border-bottom: 1px solid var(--line);
842 font-size: 13.5px;
843 color: var(--muted);
844 display: flex;
845 align-items: center;
846 gap: 10px;
847 flex-wrap: wrap;
848}
849.panel-head .name { color: var(--ink); font-weight: 600; }
850
851table.listing { width: 100%; border-collapse: collapse; }
852
853table.listing td {
854 padding: 11px 16px;
855 border-top: 1px solid var(--line);
856 vertical-align: middle;
857}
858table.listing tr:first-child td { border-top: none; }
859table.listing tr:hover td { background: var(--raised); }
860
861td.name { width: 100%; }
862td.name a { color: var(--ink); font-weight: 500; }
863td.name a:hover { color: var(--link); }
864
865td.size, td.when {
866 color: var(--muted);
867 font-size: 12.5px;
868 text-align: right;
869 white-space: nowrap;
870 font-variant-numeric: tabular-nums;
871}
872
873.chip {
874 display: inline-block;
875 min-width: 34px;
876 margin-right: 10px;
877 padding: 2px 6px;
878 border-radius: 6px;
879 background: var(--raised);
880 border: 1px solid var(--line);
881 color: var(--muted);
882 font-family: var(--mono);
883 font-size: 10.5px;
884 font-weight: 600;
885 text-align: center;
886 letter-spacing: 0.04em;
887}
888.chip.dir { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
889
890/* ── documents: a README rendered as a page ─────────────────────────────── */
891
892.doc-source { font-size: 12.5px; color: var(--muted); margin: 0 0 10px; }
893.doc-source a { color: var(--muted); }
894.doc-source a:hover { color: var(--link); }
895
896.doc {
897 background: var(--card);
898 border: 1px solid var(--line);
899 border-radius: var(--radius);
900 box-shadow: var(--shadow);
901 padding: 36px clamp(20px, 6vw, 56px);
902 font-size: 16.5px;
903 line-height: 1.7;
904 overflow-wrap: break-word;
905}
906.doc > :first-child, .md-details-body > :first-child { margin-top: 0; }
907.doc > :last-child, .md-details-body > :last-child { margin-bottom: 0; }
908
909.doc h1, .doc h2, .doc h3 { letter-spacing: -0.02em; line-height: 1.25; margin: 1.5em 0 0.5em; }
910.doc h1 { font-size: 30px; }
911.doc h2 { font-size: 22px; border-bottom: 1px solid var(--line); padding-bottom: 8px; }
912.doc h3 { font-size: 18px; }
913.doc p, .doc ul, .doc ol { margin: 0 0 1em; }
914.doc li { margin-bottom: 0.3em; }
915.doc img { max-width: 100%; border-radius: var(--radius-small); }
916.doc hr { border: none; border-top: 1px solid var(--line); margin: 2em 0; }
917.doc blockquote {
918 margin: 0 0 1em;
919 padding: 4px 18px;
920 border-left: 3px solid var(--accent);
921 color: var(--muted);
922 background: var(--raised);
923 border-radius: 0 var(--radius-small) var(--radius-small) 0;
924}
925.doc code {
926 font-family: var(--mono);
927 font-size: 0.85em;
928 background: var(--raised);
929 padding: 2px 6px;
930 border-radius: 6px;
931}
932.doc pre {
933 background: var(--raised);
934 border: 1px solid var(--line);
935 border-radius: var(--radius-small);
936 padding: 14px 16px;
937 overflow-x: auto;
938}
939.doc pre code { background: none; padding: 0; font-size: 13px; }
940.doc table { border-collapse: collapse; margin-bottom: 1em; display: block; overflow-x: auto; }
941.doc th, .doc td { border: 1px solid var(--line); padding: 7px 13px; }
942.doc th { background: var(--raised); }
943
944.doc-more {
945 display: flex;
946 gap: 12px;
947 flex-wrap: wrap;
948 margin-top: 18px;
949}
950.doc-more a {
951 flex: 1;
952 min-width: 200px;
953 padding: 14px 18px;
954 background: var(--card);
955 border: 1px solid var(--line);
956 border-radius: var(--radius);
957 color: var(--ink);
958 font-weight: 550;
959}
960.doc-more a:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); }
961
962/* a README shown under a file listing rather than as the page itself */
963.panel .doc { border: none; border-radius: 0; box-shadow: none; }
964
965/* ── the tag dialect ────────────────────────────────────────────────────── */
966
967.md-hero {
968 text-align: center;
969 padding: 30px 0 26px;
970 margin-bottom: 8px;
971}
972.md-hero h1 {
973 font-size: clamp(32px, 6vw, 44px);
974 margin: 0 0 0.3em;
975 border: none;
976 letter-spacing: -0.03em;
977}
978.md-hero p {
979 font-size: 18px;
980 color: var(--muted);
981 max-width: 44ch;
982 margin: 0 auto 1em;
983}
984.md-hero a {
985 display: inline-block;
986 margin: 4px 6px;
987 padding: 10px 22px;
988 border-radius: 999px;
989 border: 1px solid var(--line);
990 color: var(--ink);
991 font-weight: 600;
992 font-size: 15px;
993}
994.md-hero a:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); }
995.md-hero p:last-of-type a:first-child {
996 background: var(--accent);
997 border-color: var(--accent);
998 color: #ffffff;
999}
1000.md-hero p:last-of-type a:first-child:hover { opacity: 0.9; color: #ffffff; }
1001
1002.md-callout {
1003 border-radius: var(--radius-small);
1004 padding: 14px 18px;
1005 margin: 0 0 1em;
1006 background: var(--note-bg);
1007 border-left: 3px solid var(--note-ink);
1008}
1009.md-callout > :last-child { margin-bottom: 0; }
1010.md-callout-title { font-weight: 650; margin: 0 0 4px; }
1011.md-note { background: var(--note-bg); border-color: var(--note-ink); }
1012.md-note .md-callout-title { color: var(--note-ink); }
1013.md-tip { background: var(--ok-bg); border-color: var(--ok-ink); }
1014.md-tip .md-callout-title { color: var(--ok-ink); }
1015.md-warn { background: var(--warn-bg); border-color: var(--warn-ink); }
1016.md-warn .md-callout-title { color: var(--warn-ink); }
1017.md-danger { background: var(--bad-bg); border-color: var(--bad-ink); }
1018.md-danger .md-callout-title { color: var(--bad-ink); }
1019
1020.md-cards {
1021 display: grid;
1022 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1023 gap: 14px;
1024 margin: 0 0 1em;
1025}
1026.md-card {
1027 border: 1px solid var(--line);
1028 border-radius: var(--radius);
1029 padding: 16px 18px;
1030 background: var(--raised);
1031}
1032.md-card > :last-child { margin-bottom: 0; }
1033.md-card-title { font-weight: 650; margin: 0 0 6px; }
1034
1035.md-details {
1036 border: 1px solid var(--line);
1037 border-radius: var(--radius-small);
1038 margin: 0 0 1em;
1039 background: var(--raised);
1040}
1041.md-details > summary {
1042 cursor: pointer;
1043 padding: 12px 16px;
1044 font-weight: 600;
1045 user-select: none;
1046}
1047.md-details[open] > summary { border-bottom: 1px solid var(--line); }
1048.md-details-body { padding: 14px 16px; }
1049
1050/* ── code ───────────────────────────────────────────────────────────────── */
1051
1052.code { overflow-x: auto; }
1053
1054table.code-table {
1055 border-collapse: collapse;
1056 font-family: var(--mono);
1057 font-size: 12.5px;
1058 line-height: 1.55;
1059 width: 100%;
1060}
1061
1062table.code-table td.ln {
1063 width: 1%;
1064 min-width: 46px;
1065 padding: 0 12px 0 14px;
1066 text-align: right;
1067 color: var(--muted);
1068 user-select: none;
1069 vertical-align: top;
1070 border-right: 1px solid var(--line);
1071 font-variant-numeric: tabular-nums;
1072}
1073
1074table.code-table td.ln a { color: inherit; }
1075table.code-table td.src { padding: 0 14px; white-space: pre; vertical-align: top; }
1076table.code-table tr:target td { background: var(--warn-bg); }
1077table.code-table tr:target td.ln { color: var(--warn-ink); }
1078
1079/* ── history: a feed, not a table ───────────────────────────────────────── */
1080
1081.feed { display: flex; flex-direction: column; }
1082
1083.feed-item {
1084 display: flex;
1085 align-items: center;
1086 gap: 12px;
1087 padding: 12px 16px;
1088 border-top: 1px solid var(--line);
1089 color: var(--ink);
1090}
1091.feed-item:first-child { border-top: none; }
1092.feed-item:hover { background: var(--raised); text-decoration: none; }
1093
1094.avatar {
1095 flex: none;
1096 width: 36px;
1097 height: 36px;
1098 border-radius: 50%;
1099 display: inline-flex;
1100 align-items: center;
1101 justify-content: center;
1102 font-size: 13px;
1103 font-weight: 700;
1104 color: #ffffff;
1105}
1106.av0 { background: #6c53e6; }
1107.av1 { background: #1f9d8b; }
1108.av2 { background: #d76b2f; }
1109.av3 { background: #3b76d6; }
1110.av4 { background: #b84a8f; }
1111.av5 { background: #5a8f2e; }
1112.av6 { background: #c0334b; }
1113.av7 { background: #7b6ac2; }
1114
1115.feed-main { min-width: 0; display: flex; flex-direction: column; }
1116.feed-subject {
1117 font-weight: 550;
1118 overflow: hidden;
1119 text-overflow: ellipsis;
1120 white-space: nowrap;
1121}
1122.feed-meta { color: var(--muted); font-size: 12.5px; }
1123
1124code.sha {
1125 font-family: var(--mono);
1126 font-size: 12px;
1127 color: var(--muted);
1128 background: var(--raised);
1129 border: 1px solid var(--line);
1130 border-radius: 6px;
1131 padding: 1px 6px;
1132 margin-left: auto;
1133 flex: none;
1134}
1135
1136pre.message {
1137 font-family: var(--mono);
1138 font-size: 13px;
1139 white-space: pre-wrap;
1140 word-break: break-word;
1141 margin: 0;
1142 padding: 16px 18px;
1143}
1144
1145.pager { display: flex; justify-content: space-between; margin-top: 14px; }
1146.pager a {
1147 padding: 9px 18px;
1148 background: var(--card);
1149 border: 1px solid var(--line);
1150 border-radius: 999px;
1151 font-weight: 550;
1152 font-size: 14px;
1153}
1154.pager a:hover { text-decoration: none; border-color: var(--accent); }
1155.pager .spacer { flex: 1; }
1156
1157.delta {
1158 display: inline-block;
1159 min-width: 74px;
1160 margin-right: 10px;
1161 padding: 2px 8px;
1162 border-radius: 6px;
1163 font-size: 11.5px;
1164 font-weight: 650;
1165 text-align: center;
1166 background: var(--raised);
1167 color: var(--muted);
1168}
1169.delta-add { background: var(--ok-bg); color: var(--ok-ink); }
1170.delta-del { background: var(--bad-bg); color: var(--bad-ink); }
1171.delta-mod { background: var(--note-bg); color: var(--note-ink); }
1172
1173/* ── notices ────────────────────────────────────────────────────────────── */
1174
1175.empty, .notice {
1176 padding: 40px 24px;
1177 text-align: center;
1178 color: var(--muted);
1179}
1180
1181.notice strong { color: var(--ink); display: block; font-size: 18px; margin-bottom: 6px; }
1182.welcome { margin-top: 48px; }
1183
1184.foot {
1185 margin: 48px 0 30px;
1186 padding-top: 18px;
1187 border-top: 1px solid var(--line);
1188 color: var(--muted);
1189 font-size: 12.5px;
1190}
1191
1192@media (max-width: 640px) {
1193 .wrap { padding: 0 12px; }
1194 td.when { display: none; }
1195 .repo-title { font-size: 22px; }
1196 .doc { padding: 24px 18px; font-size: 16px; }
1197 .pulse-meta { display: none; }
1198}
1199"#;
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204
1205 fn markdown(source: &str) -> String {
1206 render_markdown(source, None)
1207 }
1208
1209 fn base() -> LinkBase {
1210 LinkBase {
1211 repo: "/alice/site".to_string(),
1212 reference: "main".to_string(),
1213 dir: "docs".to_string(),
1214 }
1215 }
1216
1217 #[test]
1218 fn escaping_covers_both_body_and_attribute_context() {
1219 assert_eq!(escape("<script>"), "&lt;script&gt;");
1220 assert_eq!(escape("a&b"), "a&amp;b");
1221 // Quotes matter: a repository description lands inside an attribute, and a
1222 // bare quote there ends the attribute and starts writing markup.
1223 assert_eq!(escape("\"onload=x"), "&quot;onload=x");
1224 assert_eq!(escape("'onload=x"), "&#39;onload=x");
1225 }
1226
1227 #[test]
1228 fn markdown_drops_embedded_html_rather_than_rendering_it() {
1229 // Anyone who can push can write a README. On a public repository that is a
1230 // stranger, so raw HTML in markdown is an attacker-supplied script tag.
1231 let out = markdown("# hi\n\n<script>alert(1)</script>\n\n<img src=x onerror=y>");
1232 assert!(!out.contains("<script"), "{out}");
1233 assert!(!out.contains("onerror"), "{out}");
1234 assert!(out.contains(">hi</h1>"), "{out}");
1235 }
1236
1237 #[test]
1238 fn a_markdown_link_cannot_carry_an_executable_scheme() {
1239 // A README is attacker-controlled on a public repository, and pulldown-cmark
1240 // emits destinations verbatim, so this is the one place markdown can still
1241 // execute after raw HTML has been dropped.
1242 for hostile in [
1243 "[x](javascript:alert(1))",
1244 "[x](JaVaScRiPt:alert(1))",
1245 "[x](java\tscript:alert(1))",
1246 "[x](data:text/html;base64,PHNjcmlwdD4=)",
1247 "[x](vbscript:msgbox)",
1248 "![x](javascript:alert(1))",
1249 ] {
1250 let out = markdown(hostile);
1251 assert!(
1252 !out.to_lowercase().contains("javascript:")
1253 && !out.to_lowercase().contains("vbscript:")
1254 && !out.to_lowercase().contains("data:text/html"),
1255 "{hostile} rendered as {out}"
1256 );
1257 }
1258 }
1259
1260 #[test]
1261 fn hostile_schemes_stay_blocked_when_links_are_rewritten() {
1262 // The rewriting path must not reopen the hole the plain path closed.
1263 let out = readme("[x](javascript:alert(1))\n", &base());
1264 assert!(!out.to_lowercase().contains("javascript:"), "{out}");
1265 assert!(out.contains("#blocked"), "{out}");
1266 }
1267
1268 #[test]
1269 fn ordinary_links_are_left_alone() {
1270 // Over-blocking would quietly break every README on the host.
1271 for fine in [
1272 "[x](https://example.com/a?b=c#d)",
1273 "[x](http://example.com)",
1274 "[x](docs/guide.md)",
1275 "[x](./relative)",
1276 "[x](#anchor)",
1277 "[x](mailto:a@b.c)",
1278 ] {
1279 let out = markdown(fine);
1280 assert!(!out.contains("#blocked"), "{fine} was blocked: {out}");
1281 }
1282 }
1283
1284 #[test]
1285 fn a_relative_link_resolves_against_the_documents_directory() {
1286 // `[guide](guide.md)` inside docs/README.md must open docs/guide.md as a
1287 // rendered page — otherwise a README can never link to its own folder.
1288 let out = readme("[guide](guide.md)", &base());
1289 assert!(out.contains("/alice/site/blob/main/docs/guide.md"), "{out}");
1290
1291 // An image becomes raw bytes, because a blob page is HTML, not an image.
1292 let out = readme("![shot](img/shot.png)", &base());
1293 assert!(
1294 out.contains("/alice/site/raw/main/docs/img/shot.png"),
1295 "{out}"
1296 );
1297
1298 // A directory link opens the listing.
1299 let out = readme("[examples](examples/)", &base());
1300 assert!(out.contains("/alice/site/tree/main/docs/examples"), "{out}");
1301 }
1302
1303 #[test]
1304 fn a_relative_link_cannot_climb_out_of_the_repository() {
1305 // `..` is a repository path here, not a filesystem one; the worst it may
1306 // reach is the repository root.
1307 let out = readme("[up](../../../../etc/passwd)", &base());
1308 assert!(out.contains("/alice/site/blob/main/etc/passwd"), "{out}");
1309 assert!(!out.contains("../"), "{out}");
1310 }
1311
1312 #[test]
1313 fn anchors_and_absolute_urls_survive_rewriting() {
1314 let out = readme("[a](#section) [b](https://example.com/x)", &base());
1315 assert!(out.contains("href=\"#section\""), "{out}");
1316 assert!(out.contains("https://example.com/x"), "{out}");
1317 }
1318
1319 #[test]
1320 fn headings_get_stable_anchor_ids() {
1321 // The front page of a repository is a document; a document needs working
1322 // in-page links. Duplicate headings must not produce duplicate ids.
1323 let out = markdown("## Getting started\n\n## Getting started\n");
1324 assert!(out.contains("id=\"getting-started\""), "{out}");
1325 assert!(out.contains("id=\"getting-started-2\""), "{out}");
1326 }
1327
1328 #[test]
1329 fn markdown_still_escapes_text_that_looks_like_markup() {
1330 let out = markdown("plain `<b>` and <not-a-tag>");
1331 assert!(!out.contains("<b>"), "{out}");
1332 }
1333
1334 #[test]
1335 fn a_url_segment_cannot_carry_a_query_or_fragment() {
1336 // A file legitimately named `a?b#c` would otherwise produce a link that
1337 // silently addresses a different path.
1338 assert_eq!(url_escape("a?b#c"), "a%3Fb%23c");
1339 assert_eq!(url_escape("dir/name.txt"), "dir/name.txt");
1340 assert_eq!(url_escape("a b"), "a%20b");
1341 }
1342
1343 #[test]
1344 fn the_stylesheet_url_changes_when_the_build_does() {
1345 // It is served immutable for a year, so a deploy that reused the URL would
1346 // leave every returning visitor on the previous stylesheet.
1347 assert!(style_url().contains(brand::COMMIT));
1348 }
1349
1350 #[test]
1351 fn recognised_tags_become_wrappers_and_their_content_still_renders() {
1352 let source = "{% hero %}\n# Big title\n{% /hero %}\n\n\
1353 {% callout type=\"tip\" title=\"Hint\" %}\nBe kind.\n{% /callout %}\n";
1354 let out = readme(source, &base());
1355 assert!(out.contains("class=\"md-hero\""), "{out}");
1356 assert!(out.contains(">Big title</h1>"), "{out}");
1357 assert!(out.contains("md-callout md-tip"), "{out}");
1358 assert!(out.contains("Hint"), "{out}");
1359 assert!(out.contains("Be kind."), "{out}");
1360 }
1361
1362 #[test]
1363 fn an_unknown_tag_line_disappears_but_its_content_does_not() {
1364 // A document written for a richer engine must degrade to its text, never to
1365 // visible tag soup.
1366 let out = readme(
1367 "{% fancy-widget id=\"x\" %}\nThe words.\n{% /fancy-widget %}\n",
1368 &base(),
1369 );
1370 assert!(!out.contains("fancy-widget"), "{out}");
1371 assert!(out.contains("The words."), "{out}");
1372 }
1373
1374 #[test]
1375 fn a_tag_attribute_cannot_inject_markup() {
1376 // The title lands inside the page; a quote in it must not open an attribute.
1377 let out = readme(
1378 "{% callout title=\"<img src=x onerror=y>\" %}\nhi\n{% /callout %}\n",
1379 &base(),
1380 );
1381 // The payload must survive only as inert text, never as an element.
1382 assert!(!out.contains("<img"), "{out}");
1383 assert!(out.contains("&lt;img"), "{out}");
1384 }
1385
1386 #[test]
1387 fn a_tag_inside_a_code_fence_is_shown_not_executed() {
1388 // A README documenting the dialect has to be able to quote it.
1389 let out = readme("```\n{% hero %}\n```\n", &base());
1390 assert!(!out.contains("md-hero"), "{out}");
1391 assert!(out.contains("{% hero %}"), "{out}");
1392 }
1393
1394 #[test]
1395 fn an_unclosed_tag_is_closed_rather_than_swallowing_the_page() {
1396 let out = readme("{% callout %}\nstill here\n", &base());
1397 assert!(out.contains("still here"), "{out}");
1398 assert!(out.contains("</aside>"), "{out}");
1399 }
1400
1401 #[test]
1402 fn details_render_as_a_native_disclosure() {
1403 let out = readme(
1404 "{% details summary=\"All settings\" %}\nthe table\n{% /details %}\n",
1405 &base(),
1406 );
1407 assert!(out.contains("<details class=\"md-details\">"), "{out}");
1408 assert!(out.contains("<summary>All settings</summary>"), "{out}");
1409 assert!(out.contains("the table"), "{out}");
1410 assert!(out.contains("</details>"), "{out}");
1411 }
1412
1413 #[test]
1414 fn cards_nest_inside_a_card_grid() {
1415 let out = readme(
1416 "{% cards %}\n{% card title=\"One\" %}\nfirst\n{% /card %}\n\
1417 {% card title=\"Two\" %}\nsecond\n{% /card %}\n{% /cards %}\n",
1418 &base(),
1419 );
1420 assert!(out.contains("md-cards"), "{out}");
1421 assert_eq!(out.matches("md-card\"").count(), 2, "{out}");
1422 assert!(out.contains("One"), "{out}");
1423 assert!(out.contains("second"), "{out}");
1424 }
1425
1426 #[test]
1427 fn slugs_read_like_the_heading() {
1428 assert_eq!(slugify("Getting started"), "getting-started");
1429 assert_eq!(slugify("What's new?"), "what-s-new");
1430 assert_eq!(slugify(" -- "), "");
1431 assert_eq!(slugify("Étape 1"), "étape-1");
1432 }
1433}