1use std::path::Path;
18
19pub const SECTIONS: [&str; 5] = [
22 "Where we are",
23 "What you should see",
24 "Try it",
25 "Reference",
26 "Already checked",
27];
28
29const TRY_IT: usize = 2;
30const REFERENCE: usize = 3;
31
32pub const MAX_BYTES: u64 = 1024 * 1024;
34
35#[derive(Debug, Clone)]
37struct Section<'a> {
38 heading: &'a str,
39 body: Vec<&'a str>,
40}
41
42#[derive(Debug)]
44pub struct Guide<'a> {
45 preamble: Vec<&'a str>,
46 sections: Vec<Section<'a>>,
47}
48
49fn normalise(heading: &str) -> String {
52 let words: String = heading
53 .chars()
54 .map(|c| {
55 if c.is_alphanumeric() {
56 c.to_lowercase().next().unwrap_or(c)
57 } else {
58 ' '
59 }
60 })
61 .collect();
62 words.split_whitespace().collect::<Vec<_>>().join(" ")
63}
64
65fn names_section(heading: &str, name: &str) -> bool {
71 if normalise(heading) == normalise(name) {
72 return true;
73 }
74 let h = heading.trim_start_matches(|c: char| !c.is_alphanumeric());
75 let Some(head) = h.get(..name.len()) else {
76 return false;
77 };
78 if !head.eq_ignore_ascii_case(name) {
79 return false;
80 }
81 let rest = &h[name.len()..];
82 if rest.chars().next().is_some_and(char::is_alphanumeric) {
83 return false;
84 }
85 let rest = rest.trim_start();
86 rest.is_empty() || rest.starts_with(['(', '-', 'โ', 'โ', ':'])
87}
88
89fn h2(line: &str) -> Option<&str> {
90 let rest = line.strip_prefix("## ")?;
91 Some(rest.trim().trim_end_matches('#').trim())
92}
93
94fn outside_fences(lines: &[&str]) -> Vec<bool> {
96 let mut inside = false;
97 lines
98 .iter()
99 .map(|l| {
100 if l.trim_start().starts_with("```") {
101 inside = !inside;
102 return false;
103 }
104 !inside
105 })
106 .collect()
107}
108
109pub fn parse(text: &str) -> Guide<'_> {
110 let lines: Vec<&str> = text.lines().collect();
111 let open = outside_fences(&lines);
112 let mut g = Guide {
113 preamble: Vec::new(),
114 sections: Vec::new(),
115 };
116 for (line, open) in lines.iter().zip(open) {
117 match h2(line).filter(|_| open) {
118 Some(heading) => g.sections.push(Section {
119 heading,
120 body: Vec::new(),
121 }),
122 None => match g.sections.last_mut() {
123 Some(s) => s.body.push(line),
124 None => g.preamble.push(line),
125 },
126 }
127 }
128 g
129}
130
131impl<'a> Guide<'a> {
132 fn section(&self, name: &str) -> Option<&Section<'a>> {
134 self.sections
135 .iter()
136 .find(|s| names_section(s.heading, name))
137 }
138}
139
140fn numbered(line: &str) -> Option<(usize, &str)> {
143 let t = line.trim_start();
144 let digits = t.chars().take_while(|c| c.is_ascii_digit()).count();
145 if digits == 0 || digits > 9 {
146 return None;
147 }
148 let rest = &t[digits..];
149 let rest = rest.strip_prefix('.').or_else(|| rest.strip_prefix(')'))?;
150 if !(rest.is_empty() || rest.starts_with(' ')) {
151 return None;
152 }
153 Some((t[..digits].parse().ok()?, rest.trim_start()))
154}
155
156fn bullet(line: &str) -> Option<&str> {
157 let t = line.trim_start();
158 ["- ", "* ", "+ "]
159 .iter()
160 .find_map(|m| t.strip_prefix(m))
161 .or_else(|| (t == "-" || t == "*").then_some(""))
162}
163
164fn has_url(line: &str) -> bool {
165 line.contains("http://") || line.contains("https://")
166}
167
168pub fn check(text: &str) -> Vec<String> {
170 let g = parse(text);
171 let mut missing = Vec::new();
172 for name in SECTIONS {
173 match g.section(name) {
174 None => missing.push(format!("the section `## {name}`")),
175 Some(s) if s.body.iter().all(|l| l.trim().is_empty()) => {
176 missing.push(format!("any text under `## {name}`"))
177 }
178 Some(_) => {}
179 }
180 }
181 if let Some(s) = g.section(SECTIONS[TRY_IT]) {
182 let open = outside_fences(&s.body);
183 let steps = s
184 .body
185 .iter()
186 .zip(&open)
187 .any(|(l, o)| *o && numbered(l).is_some());
188 if !steps {
189 missing.push(
190 "a numbered step (`1. โฆ`) under `## Try it`: each click, named by its label, and what should appear".to_string(),
191 );
192 }
193 if !s.body.iter().any(|l| has_url(l)) {
194 missing
195 .push("an http(s) URL under `## Try it`: where the first step starts".to_string());
196 }
197 }
198 missing
199}
200
201pub const DIFFERENCES: &str = "Differences from the mockup";
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209enum Role {
210 Design,
211 Built,
212 Before,
213}
214
215fn role(src: &str) -> Option<(Role, String)> {
218 let name = file_name(src);
219 let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(&name);
220 [
221 ("artboard", Role::Design),
222 ("mockup", Role::Design),
223 ("after", Role::Built),
224 ("live", Role::Built),
225 ("before", Role::Before),
226 ]
227 .iter()
228 .find_map(|(word, r)| {
229 let i = stem.find(word)?;
230 Some((*r, format!("{}{}", &stem[..i], &stem[i + word.len()..])))
231 })
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct Proof {
237 pub state: String,
240 pub design: String,
241 pub design_alt: String,
242 pub built: String,
243 pub built_alt: String,
244}
245
246fn images_with_alt(text: &str) -> Vec<(String, String)> {
248 let mut found = Vec::new();
249 let mut rest = text;
250 while let Some(i) = rest.find("![") {
251 rest = &rest[i + 2..];
252 let Some((alt, src, after)) = link_parts(rest) else {
253 continue;
254 };
255 found.push((alt.to_string(), src.to_string()));
256 rest = after;
257 }
258 found
259}
260
261pub fn proofs(text: &str) -> Vec<Proof> {
265 let all = images_with_alt(text);
266 let mut out: Vec<Proof> = Vec::new();
267 for (alt, src) in &all {
268 let Some((Role::Design, key)) = role(src) else {
269 continue;
270 };
271 if out.iter().any(|p| p.state == key) {
272 continue;
273 }
274 let built = all
275 .iter()
276 .find(|(_, s)| role(s) == Some((Role::Built, key.clone())));
277 if let Some((balt, bsrc)) = built {
278 out.push(Proof {
279 state: key,
280 design: src.clone(),
281 design_alt: alt.clone(),
282 built: bsrc.clone(),
283 built_alt: balt.clone(),
284 });
285 }
286 }
287 out
288}
289
290fn before_image(text: &str) -> Option<(String, String)> {
292 images_with_alt(text)
293 .into_iter()
294 .find(|(_, s)| matches!(role(s), Some((Role::Before, _))))
295}
296
297fn local_file(src: &str, folder: &Path) -> Option<std::path::PathBuf> {
300 let s = src.trim();
301 if let Some(p) = s.strip_prefix("file://") {
302 return Some(std::path::PathBuf::from(p));
303 }
304 if s.starts_with('/') {
305 return Some(std::path::PathBuf::from(s));
306 }
307 if s.contains(':') {
308 return None;
309 }
310 Some(folder.join(s))
311}
312
313fn viewport(lines: &[&str]) -> Option<String> {
315 lines.iter().find_map(|l| {
316 let low = l.to_ascii_lowercase();
317 let i = low.find("viewport")?;
318 let rest = low[i + "viewport".len()..].trim_start_matches(['*', ':', ' ']);
319 let digits = rest.chars().take_while(char::is_ascii_digit).count();
320 (digits > 0 && rest[digits..].trim_start().starts_with("px"))
321 .then(|| plain(l.trim().trim_start_matches(['-', '*', ' '])))
322 })
323}
324
325fn differences(lines: &[&str]) -> Result<(), String> {
328 let mut bullets = 0;
329 let mut unclassified = 0;
330 let mut none = false;
331 for l in lines {
332 let low = l.trim().to_ascii_lowercase();
333 if low.trim_end_matches('.') == "none" {
334 none = true;
335 continue;
336 }
337 let Some(b) = bullet(l) else { continue };
338 bullets += 1;
339 let b = b.trim().to_ascii_lowercase();
340 let b = b.trim_start_matches("**");
341 let classified = ["fixed", "deliberate"].iter().any(|w| {
342 b.strip_prefix(w)
343 .map(|r| r.trim_start_matches("**"))
344 .and_then(|r| r.strip_prefix(':'))
345 .is_some_and(|r| !r.trim().is_empty())
346 });
347 if !classified {
348 unclassified += 1;
349 }
350 }
351 if unclassified > 0 {
352 return Err(format!(
353 "{unclassified} bullet(s) under `## {DIFFERENCES}` say neither `fixed: โฆ` nor `deliberate: <reason>`"
354 ));
355 }
356 if bullets == 0 && !none {
357 return Err(format!(
358 "`## {DIFFERENCES}` lists nothing: write `None`, or one bullet per difference (`- fixed: โฆ` / `- deliberate: <reason>`)"
359 ));
360 }
361 Ok(())
362}
363
364pub fn check_mockup(text: &str, screens: &[String], folder: &Path) -> Vec<String> {
368 let g = parse(text);
369 let mut missing = Vec::new();
370 let reference: Vec<&str> = g
371 .section(SECTIONS[REFERENCE])
372 .map(|s| s.body.clone())
373 .unwrap_or_default();
374 for screen in screens {
375 if !reference.iter().any(|l| l.contains(screen.as_str())) {
376 missing.push(format!("the screen's path `{screen}` under `## Reference`"));
377 }
378 }
379 if viewport(&reference).is_none() {
380 missing.push(
381 "a viewport line under `## Reference`: `Viewport: 1120px ยท light`, the width and theme both images were taken at".to_string(),
382 );
383 }
384 let found = proofs(text);
385 if !found.iter().any(|p| p.state.is_empty()) {
386 missing.push(format!(
387 "the picked artboard beside the built screen under `## Reference`: `artboard.png` and `after.png` in {}",
388 folder.display()
389 ));
390 }
391 for p in &found {
392 for src in [&p.design, &p.built] {
393 match local_file(src, folder) {
394 Some(f) if f.is_file() => {}
395 Some(f) => missing.push(format!(
396 "the image {} (the guide shows `{src}`)",
397 f.display()
398 )),
399 None => missing.push(format!(
400 "`{src}` as a file beside the guide: a remote image cannot be checked"
401 )),
402 }
403 }
404 }
405 match g.section(DIFFERENCES) {
406 None => missing.push(format!("the section `## {DIFFERENCES}`")),
407 Some(s) => {
408 if let Err(why) = differences(&s.body) {
409 missing.push(why);
410 }
411 }
412 }
413 missing
414}
415
416pub fn png_width(path: &Path) -> Option<u32> {
418 use std::io::Read;
419 let mut head = [0u8; 24];
420 std::fs::File::open(path).ok()?.read_exact(&mut head).ok()?;
421 if head[..8] != [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a] || &head[12..16] != b"IHDR" {
422 return None;
423 }
424 Some(u32::from_be_bytes([head[16], head[17], head[18], head[19]]))
425}
426
427pub fn width_warnings(text: &str, folder: &Path) -> Vec<String> {
430 proofs(text)
431 .iter()
432 .filter_map(|p| {
433 let d = png_width(&local_file(&p.design, folder)?)?;
434 let b = png_width(&local_file(&p.built, folder)?)?;
435 let (lo, hi) = (d.min(b), d.max(b));
436 (hi - lo > hi / 10).then(|| {
437 format!(
438 "{} is {d}px wide and {} is {b}px: take the built screen at the artboard's width",
439 p.design, p.built
440 )
441 })
442 })
443 .collect()
444}
445
446pub fn escape(s: &str) -> String {
449 let mut out = String::with_capacity(s.len());
450 for c in s.chars() {
451 match c {
452 '&' => out.push_str("&"),
453 '<' => out.push_str("<"),
454 '>' => out.push_str(">"),
455 '"' => out.push_str("""),
456 '\'' => out.push_str("'"),
457 c => out.push(c),
458 }
459 }
460 out
461}
462
463fn safe_url(url: &str) -> Option<&str> {
467 let u = url.trim();
468 if u.is_empty() || u.chars().any(|c| c.is_whitespace() || c.is_control()) {
469 return None;
470 }
471 let scheme_end = u.find(':');
472 let path_start = u.find(['/', '?', '#']).unwrap_or(u.len());
473 match scheme_end {
474 Some(i) if i < path_start => {
475 let scheme = u[..i].to_ascii_lowercase();
476 let drive = i == 1 && u[..1].chars().all(|c| c.is_ascii_alphabetic());
478 (drive || matches!(scheme.as_str(), "http" | "https" | "mailto" | "file")).then_some(u)
479 }
480 _ => Some(u),
481 }
482}
483
484fn file_name(src: &str) -> String {
486 src.rsplit(['/', '\\'])
487 .next()
488 .unwrap_or(src)
489 .to_ascii_lowercase()
490}
491
492pub fn images(text: &str) -> Vec<String> {
494 let mut found = Vec::new();
495 let mut rest = text;
496 while let Some(i) = rest.find("![") {
497 rest = &rest[i + 2..];
498 let Some((_, src, after)) = link_parts(rest) else {
499 continue;
500 };
501 found.push(src.to_string());
502 rest = after;
503 }
504 found
505}
506
507fn link_parts(s: &str) -> Option<(&str, &str, &str)> {
509 let close = s.find("](")?;
510 let text = &s[..close];
511 if text.contains('\n') {
512 return None;
513 }
514 let after = &s[close + 2..];
515 let end = after.find(')')?;
516 let src = after[..end].trim();
517 let src = src.split_once(" \"").map(|(s, _)| s).unwrap_or(src);
519 Some((text, src, &after[end + 1..]))
520}
521
522fn pair(text: &str) -> Option<(String, String)> {
525 let all = images(text);
526 let before = all.iter().find(|s| file_name(s).contains("before"))?;
527 let after = all
528 .iter()
529 .find(|s| *s != before && file_name(s).contains("after"))?;
530 Some((before.clone(), after.clone()))
531}
532
533struct Ctx<'p> {
534 paired: &'p [String],
536}
537
538impl Ctx<'_> {
539 fn is_paired(&self, src: &str) -> bool {
540 self.paired.iter().any(|s| s == src)
541 }
542}
543
544fn img(alt: &str, src: &str) -> String {
545 format!(
546 "<img src=\"{}\" alt=\"{}\" loading=\"lazy\">",
547 escape(src),
548 escape(alt)
549 )
550}
551
552fn inline(s: &str, ctx: &Ctx) -> String {
555 let mut out = String::new();
556 let mut i = 0;
557 let b = s.as_bytes();
558 while i < s.len() {
559 let rest = &s[i..];
560 if rest.starts_with('\\') && rest.len() > 1 {
561 let c = rest[1..].chars().next().unwrap_or(' ');
562 if c.is_ascii_punctuation() {
563 out.push_str(&escape(&c.to_string()));
564 i += 1 + c.len_utf8();
565 continue;
566 }
567 }
568 if let Some(r) = rest.strip_prefix('`') {
569 if let Some(end) = r.find('`') {
570 out.push_str(&format!("<code>{}</code>", escape(&r[..end])));
571 i += end + 2;
572 continue;
573 }
574 }
575 if let Some(r) = rest.strip_prefix("**") {
576 if let Some(end) = r.find("**").filter(|&e| e > 0) {
577 out.push_str(&format!("<strong>{}</strong>", inline(&r[..end], ctx)));
578 i += end + 4;
579 continue;
580 }
581 }
582 if let Some(r) = rest.strip_prefix("![") {
583 if let Some((alt, src, after)) = link_parts(r) {
584 if let Some(src) = safe_url(src) {
585 if !ctx.is_paired(src) {
586 out.push_str(&img(alt, src));
587 }
588 i = s.len() - after.len();
589 continue;
590 }
591 }
592 }
593 if let Some(r) = rest.strip_prefix('[') {
594 if let Some((text, url, after)) = link_parts(r) {
595 if let Some(url) = safe_url(url) {
596 out.push_str(&format!(
597 "<a href=\"{}\">{}</a>",
598 escape(url),
599 inline(text, ctx)
600 ));
601 i = s.len() - after.len();
602 continue;
603 }
604 }
605 }
606 let boundary = i == 0 || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'/');
607 if boundary && (rest.starts_with("http://") || rest.starts_with("https://")) {
608 let end = rest
609 .find(|c: char| c.is_whitespace() || c == '<' || c == '>' || c == '"')
610 .unwrap_or(rest.len());
611 let url = rest[..end].trim_end_matches(['.', ',', ';', ':', '!', '?', ')']);
612 out.push_str(&format!("<a href=\"{0}\">{0}</a>", escape(url)));
613 i += url.len();
614 continue;
615 }
616 let c = rest.chars().next().unwrap_or(' ');
617 out.push_str(&escape(&c.to_string()));
618 i += c.len_utf8();
619 }
620 out
621}
622
623fn indent(line: &str) -> usize {
624 line.len() - line.trim_start().len()
625}
626
627fn marker(line: &str) -> Option<(bool, usize, &str, usize)> {
630 let ind = indent(line);
631 if let Some(text) = bullet(line) {
632 return Some((false, 1, text, ind + 2));
633 }
634 let (n, text) = numbered(line)?;
635 let t = line.trim_start();
636 let width = t.len() - text.len();
637 Some((true, n, text, ind + width.max(1)))
638}
639
640fn heading(line: &str) -> Option<(usize, &str)> {
641 let t = line.trim_start();
642 let level = t.chars().take_while(|&c| c == '#').count();
643 if level == 0 || level > 6 {
644 return None;
645 }
646 let rest = &t[level..];
647 if !(rest.is_empty() || rest.starts_with(' ')) {
648 return None;
649 }
650 Some((level, rest.trim().trim_end_matches('#').trim()))
651}
652
653fn starts_block(line: &str) -> bool {
654 let t = line.trim_start();
655 t.starts_with("```") || t.starts_with('>') || heading(line).is_some() || marker(line).is_some()
656}
657
658fn blocks(lines: &[&str], ctx: &Ctx, tight: bool, out: &mut String) {
661 let mut i = 0;
662 while i < lines.len() {
663 let line = lines[i];
664 let t = line.trim();
665 if t.is_empty() {
666 i += 1;
667 continue;
668 }
669 if t.starts_with("```") {
670 let mut j = i + 1;
671 let mut code = Vec::new();
672 while j < lines.len() && !lines[j].trim_start().starts_with("```") {
673 code.push(lines[j]);
674 j += 1;
675 }
676 out.push_str(&format!(
677 "<pre><code>{}</code></pre>\n",
678 escape(&code.join("\n"))
679 ));
680 i = j + 1;
681 continue;
682 }
683 if let Some((level, text)) = heading(line) {
684 let level = level.max(3);
686 out.push_str(&format!("<h{level}>{}</h{level}>\n", inline(text, ctx)));
687 i += 1;
688 continue;
689 }
690 if t.starts_with('>') {
691 let mut quoted = Vec::new();
692 while i < lines.len() && lines[i].trim_start().starts_with('>') {
693 let q = lines[i].trim_start()[1..].strip_prefix(' ');
694 quoted.push(q.unwrap_or(&lines[i].trim_start()[1..]));
695 i += 1;
696 }
697 out.push_str("<blockquote>\n");
698 blocks("ed, ctx, false, out);
699 out.push_str("</blockquote>\n");
700 continue;
701 }
702 if let Some((ordered, start, _, _)) = marker(line) {
703 i = list(lines, i, ordered, start, ctx, out);
704 continue;
705 }
706 let mut para = vec![t];
708 i += 1;
709 while i < lines.len() && !lines[i].trim().is_empty() && !starts_block(lines[i]) {
710 para.push(lines[i].trim());
711 i += 1;
712 }
713 let text = para.join(" ");
714 let only_image = text.starts_with(".unwrap_or(2)];
719 out.push_str(&format!(
720 "<figure>{html}<figcaption>{}</figcaption></figure>\n",
721 escape(alt)
722 ));
723 }
724 } else {
725 let html = inline(&text, ctx);
726 let html = html.trim();
727 if html.is_empty() {
728 } else if tight {
730 out.push_str(html);
731 } else {
732 out.push_str(&format!("<p>{html}</p>\n"));
733 }
734 }
735 }
736}
737
738fn list(
740 lines: &[&str],
741 mut i: usize,
742 ordered: bool,
743 start: usize,
744 ctx: &Ctx,
745 out: &mut String,
746) -> usize {
747 let base = indent(lines[i]);
748 let (open, close) = if ordered {
749 (
750 if start == 1 {
751 "<ol>".to_string()
752 } else {
753 format!("<ol start=\"{start}\">")
754 },
755 "</ol>",
756 )
757 } else {
758 ("<ul>".to_string(), "</ul>")
759 };
760 out.push_str(&open);
761 out.push('\n');
762 while i < lines.len() {
763 let Some((o, _, text, width)) = marker(lines[i]) else {
764 break;
765 };
766 if o != ordered || indent(lines[i]) != base {
767 break;
768 }
769 let mut body = vec![text];
772 let mut j = i + 1;
773 while j < lines.len() {
774 let l = lines[j];
775 if l.trim().is_empty() {
776 let next = lines[j + 1..].iter().find(|l| !l.trim().is_empty());
778 if next.is_some_and(|n| indent(n) > base) {
779 body.push("");
780 j += 1;
781 continue;
782 }
783 break;
784 }
785 if indent(l) <= base && (marker(l).is_some() || starts_block(l)) {
786 break;
787 }
788 let strip = indent(l).min(width);
789 body.push(&l[strip..]);
790 j += 1;
791 }
792 let tight = !body.contains(&"");
793 out.push_str("<li>");
794 blocks(&body, ctx, tight, out);
795 out.push_str("</li>\n");
796 i = j;
797 while i < lines.len() && lines[i].trim().is_empty() {
799 let next = lines[i + 1..].iter().find(|l| !l.trim().is_empty());
800 if next.is_some_and(|n| indent(n) == base && marker(n).is_some_and(|m| m.0 == ordered))
801 {
802 i += 1;
803 } else {
804 break;
805 }
806 }
807 }
808 out.push_str(close);
809 out.push('\n');
810 i
811}
812
813fn plain(s: &str) -> String {
816 let html = inline(s, &Ctx { paired: &[] });
817 let mut out = String::new();
818 let mut tag = false;
819 for c in html.chars() {
820 match c {
821 '<' => tag = true,
822 '>' => tag = false,
823 c if !tag => out.push(c),
824 _ => {}
825 }
826 }
827 out.trim().to_string()
828}
829
830fn slug(name: &str) -> String {
831 normalise(name).replace(' ', "-")
832}
833
834pub struct Page<'a> {
836 pub label: &'a str,
838 pub commit: &'a str,
839 pub url: &'a str,
840}
841
842fn headline(g: &Guide) -> String {
844 let Some(s) = g.section(SECTIONS[0]) else {
845 return String::new();
846 };
847 let first = s
848 .body
849 .iter()
850 .map(|l| l.trim())
851 .find(|l| !l.is_empty())
852 .unwrap_or("");
853 let first = bullet(first)
854 .or_else(|| numbered(first).map(|(_, t)| t))
855 .unwrap_or(first);
856 plain(first)
857}
858
859pub fn render(text: &str, page: &Page) -> String {
862 let g = parse(text);
863 let found = proofs(text);
864 let paired = if found.is_empty() { pair(text) } else { None };
865 let before = if found.is_empty() {
866 None
867 } else {
868 before_image(text)
869 };
870 let mut skipped: Vec<String> = Vec::new();
871 if let Some((b, a)) = &paired {
872 skipped.extend([b.clone(), a.clone()]);
873 }
874 for p in &found {
875 skipped.extend([p.design.clone(), p.built.clone()]);
876 }
877 if let Some((_, b)) = &before {
878 skipped.push(b.clone());
879 }
880 let ctx = Ctx { paired: &skipped };
881 let direction = direction(text);
882 let headline = headline(&g);
883 let title = if headline.is_empty() {
884 escape(page.label)
885 } else {
886 format!("{} โ {headline}", escape(page.label))
887 };
888 let mut body = String::new();
889 let pre: Vec<&str> = g
891 .preamble
892 .iter()
893 .copied()
894 .filter(|l| !l.trim_start().starts_with("# "))
895 .collect();
896 if pre.iter().any(|l| !l.trim().is_empty()) {
897 body.push_str("<section class=\"intro\">\n");
898 blocks(&pre, &ctx, false, &mut body);
899 body.push_str("</section>\n");
900 }
901 let mut order: Vec<&Section> = SECTIONS.iter().filter_map(|n| g.section(n)).collect();
903 for s in &g.sections {
904 if !order.iter().any(|o| std::ptr::eq(*o, s)) {
905 order.push(s);
906 }
907 }
908 let reference = normalise(SECTIONS[REFERENCE]);
909 for s in order {
910 let id = slug(s.heading);
911 body.push_str(&format!(
912 "<section id=\"{}\">\n<h2>{}</h2>\n",
913 escape(&id),
914 inline(s.heading, &ctx)
915 ));
916 if normalise(s.heading) == reference {
917 let view = viewport(&s.body).map(|v| escape(&v)).unwrap_or_default();
918 for (i, p) in found.iter().enumerate() {
919 let state = p.state.trim_matches(['-', '_', ' ']);
920 let design_caption = match &direction {
921 Some(d) => format!("Mockup, direction {d}"),
922 None => "Mockup".to_string(),
923 };
924 body.push_str(&format!(
925 "<div class=\"proof\">\n{state_line}<input type=\"checkbox\" id=\"overlay-{i}\" class=\"overlay\"><label for=\"overlay-{i}\">Overlay the built screen on the mockup</label>\n<div class=\"stack\">\n<figure class=\"design\"><a href=\"{dsrc}\">{dimg}</a><figcaption>{dcap}{view_line}</figcaption></figure>\n<figure class=\"built\"><a href=\"{bsrc}\">{bimg}</a><figcaption>Built, <code>{sha}</code>{view_line}</figcaption></figure>\n</div>\n</div>\n",
926 state_line = if state.is_empty() {
927 String::new()
928 } else {
929 format!("<p class=\"state\">State: {}</p>\n", escape(state))
930 },
931 dsrc = escape(&p.design),
932 dimg = img(alt_or(&p.design_alt, &design_caption), &p.design),
933 dcap = escape(&design_caption),
934 bsrc = escape(&p.built),
935 bimg = img(alt_or(&p.built_alt, "Built screen"), &p.built),
936 sha = escape(&page.commit[..page.commit.len().min(7)]),
937 view_line = if view.is_empty() { String::new() } else { format!(" ยท {view}") },
938 ));
939 }
940 if let Some((alt, src)) = &before {
941 body.push_str(&format!(
942 "<figure class=\"before\"><a href=\"{}\">{}</a><figcaption>Before</figcaption></figure>\n",
943 escape(src),
944 img(alt_or(alt, "Before"), src)
945 ));
946 }
947 if let Some((before, after)) = &paired {
948 body.push_str(&format!(
949 "<div class=\"pair\">\n<figure>{}<figcaption>Before</figcaption></figure>\n<figure>{}<figcaption>After</figcaption></figure>\n</div>\n",
950 img("Before", before),
951 img("After", after)
952 ));
953 }
954 }
955 blocks(&s.body, &ctx, false, &mut body);
956 body.push_str("</section>\n");
957 }
958 let url = safe_url(page.url).unwrap_or("#");
959 format!(
960 "<!doctype html>
961<html lang=\"en\">
962<head>
963<meta charset=\"utf-8\">
964<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
965<meta name=\"color-scheme\" content=\"light dark\">
966<title>{title}</title>
967<style>{STYLE}</style>
968</head>
969<body>
970<main>
971<header>
972<p class=\"label\">{label}</p>
973<h1>{h1}</h1>
974<p><a class=\"open\" href=\"{url}\" target=\"_blank\" rel=\"noopener\">Open the app</a></p>
975<p class=\"url\"><code>{url_text}</code></p>
976</header>
977{body}<footer>Guide for commit <code>{commit}</code> ยท rendered by <code>amont-agent preview register</code></footer>
978</main>
979</body>
980</html>
981",
982 label = escape(page.label),
983 h1 = if headline.is_empty() {
984 escape(page.label)
985 } else {
986 headline.clone()
987 },
988 url = escape(url),
989 url_text = escape(page.url),
990 commit = escape(page.commit),
991 )
992}
993
994const STYLE: &str = "
995:root{--bg:#fbfbfa;--fg:#1d1d1f;--muted:#5f6368;--line:#dcdcdc;--card:#fff;--accent:#0b57d0;--accent-fg:#fff;--code:#f1f1ef}
996@media (prefers-color-scheme: dark){:root{--bg:#161618;--fg:#ececec;--muted:#a0a0a8;--line:#35353a;--card:#1f1f22;--accent:#8ab4f8;--accent-fg:#0b0b0c;--code:#2a2a2e}}
997*{box-sizing:border-box}
998body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.55 system-ui,-apple-system,'Segoe UI',sans-serif}
999main{max-width:980px;margin:0 auto;padding:24px 16px 48px}
1000header{border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:8px}
1001.label{margin:0;color:var(--muted);font:600 14px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}
1002h1{font-size:26px;line-height:1.25;margin:6px 0 16px}
1003h2{font-size:20px;margin:0 0 8px}
1004h3{font-size:17px;margin:16px 0 6px}
1005section{padding:16px 0;border-bottom:1px solid var(--line)}
1006a{color:var(--accent)}
1007a.open{display:inline-block;background:var(--accent);color:var(--accent-fg);text-decoration:none;font-weight:700;font-size:20px;padding:12px 24px;border-radius:10px}
1008a.open:hover{filter:brightness(1.08)}
1009.url{margin:8px 0 0;color:var(--muted);overflow-wrap:anywhere}
1010code{font:0.9em ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--code);padding:1px 5px;border-radius:4px}
1011pre{background:var(--code);padding:12px;border-radius:8px;overflow-x:auto}
1012pre code{padding:0;background:none}
1013ol,ul{padding-left:24px}
1014li{margin:6px 0}
1015#try-it ol>li{margin:10px 0}
1016blockquote{margin:8px 0;padding:4px 12px;border-left:3px solid var(--line);color:var(--muted)}
1017img{max-width:100%;height:auto;border:1px solid var(--line);border-radius:8px;background:var(--card)}
1018figure{margin:12px 0}
1019figcaption{color:var(--muted);font-size:14px;margin-top:4px}
1020.proof{margin:12px 0 20px}
1021.proof .state{margin:0 0 6px;font-weight:600}
1022.proof label{display:inline-block;margin:0 0 8px;color:var(--muted);font-size:14px;cursor:pointer}
1023.proof .overlay{margin-right:6px}
1024.stack{display:grid;grid-template-columns:1fr;gap:16px}
1025.stack figure{margin:0}
1026.stack img{width:100%}
1027.overlay:checked+label+.stack{gap:0}
1028.overlay:checked+label+.stack figure{grid-area:1/1}
1029.overlay:checked+label+.stack .built img{opacity:.5}
1030.overlay:checked+label+.stack .built figcaption{display:none}
1031.pair{display:grid;grid-template-columns:1fr 1fr;gap:16px}
1032.pair figure{margin:0}
1033@media (max-width:640px){.pair{grid-template-columns:1fr}}
1034footer{margin-top:24px;color:var(--muted);font-size:14px}
1035";
1036
1037fn alt_or<'a>(alt: &'a str, role: &'a str) -> &'a str {
1039 if alt.trim().is_empty() {
1040 role
1041 } else {
1042 alt
1043 }
1044}
1045
1046fn direction(text: &str) -> Option<String> {
1048 let low = text.to_ascii_lowercase();
1049 let mut from = 0;
1050 while let Some(i) = low[from..].find("direction ") {
1051 let at = from + i + "direction ".len();
1052 let word: String = text[at..]
1053 .chars()
1054 .take_while(|c| c.is_alphanumeric())
1055 .collect();
1056 if word.chars().count() == 1 && word.chars().all(|c| c.is_ascii_alphabetic()) {
1057 return Some(word.to_ascii_uppercase());
1058 }
1059 from = at;
1060 }
1061 None
1062}
1063
1064pub fn file_url(path: &Path) -> String {
1067 let raw = path.to_string_lossy();
1068 let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
1069 let mut p = raw.replace('\\', "/");
1070 if !p.starts_with('/') {
1071 p.insert(0, '/');
1072 }
1073 let mut out = String::from("file://");
1074 for b in p.bytes() {
1075 if b.is_ascii_alphanumeric() || b"/-_.~:".contains(&b) {
1076 out.push(b as char);
1077 } else {
1078 out.push_str(&format!("%{b:02X}"));
1079 }
1080 }
1081 out
1082}
1083
1084pub fn missing_images(text: &str, dir: &Path) -> Vec<String> {
1086 images(text)
1087 .into_iter()
1088 .filter(|s| safe_url(s).is_some() && !s.contains(':') && !s.starts_with('/'))
1089 .filter(|s| !dir.join(s).is_file())
1090 .collect()
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096
1097 const GOOD: &str = "\
1098# ignored title
1099
1100## ๐ Where we are
1101duro-app, branch `feat/x`, plan **Settings**.
1102
1103## What you should see
1104The **Save** button moved.
1105
1106## ๐ Try it
11071. Open http://localhost:5173/settings
1108 - You should see the form.
11092. Click **Save**, top right.
1110
1111## Reference
1112 
1113
1114## Already checked
1115- console clean
1116";
1117
1118 #[test]
1119 fn a_complete_guide_passes() {
1120 assert!(check(GOOD).is_empty(), "{:?}", check(GOOD));
1121 }
1122
1123 #[test]
1124 fn headings_match_without_emoji_or_case() {
1125 assert_eq!(normalise("๐ Where we are:"), "where we are");
1126 assert_eq!(normalise("TRY IT"), "try it");
1127 }
1128
1129 #[test]
1130 fn a_heading_with_a_trailing_note_matches() {
1131 for (heading, name) in [
1132 ("๐ Try it (about 2 minutes)", "Try it"),
1133 ("Try it โ 2 min", "Try it"),
1134 ("Reference - screenshots", "Reference"),
1135 ("๐ Where we are: the masthead", "Where we are"),
1136 ] {
1137 assert!(names_section(heading, name), "{heading:?} names {name:?}");
1138 }
1139 let text = GOOD.replace("## ๐ Try it\n", "## ๐ Try it (about 2 minutes)\n");
1140 assert_ne!(text, GOOD, "the fixture has a `## ๐ Try it` heading");
1141 let m = check(&text);
1142 assert!(!m.iter().any(|m| m.contains("Try it")), "{m:?}");
1143 }
1144
1145 const MOCKUP_GUIDE: &str = "\
1146## Where we are
1147app, branch `feat/x`, direction B.
1148
1149## What you should see
1150A pill.
1151
1152## Try it
11531. Open http://localhost:5173/
1154
1155## Reference
1156Artboards: docs/mockups/pill
1157Viewport: 1120px ยท light
1158
1159 
1160
1161## Already checked
1162- console clean
1163
1164## Differences from the mockup
1165- fixed: the separate History segment is gone
1166- deliberate: a hidden live region copy, for screen readers
1167";
1168
1169 fn folder_with(files: &[&str]) -> std::path::PathBuf {
1170 let d = std::env::temp_dir().join(format!(
1171 "amont-agent-guide-{}-{:?}",
1172 std::process::id(),
1173 std::thread::current().id()
1174 ));
1175 let _ = std::fs::remove_dir_all(&d);
1176 std::fs::create_dir_all(&d).unwrap();
1177 for f in files {
1178 std::fs::write(d.join(f), b"x").unwrap();
1179 }
1180 d
1181 }
1182
1183 #[test]
1184 fn a_complete_mockup_guide_lacks_nothing() {
1185 let d = folder_with(&["artboard.png", "after.png"]);
1186 let m = check_mockup(MOCKUP_GUIDE, &["docs/mockups/pill".to_string()], &d);
1187 assert!(m.is_empty(), "{m:?}");
1188 assert!(check(MOCKUP_GUIDE).is_empty());
1189 }
1190
1191 #[test]
1192 fn mockup_mode_names_every_missing_piece() {
1193 let d = folder_with(&[]);
1194 let m = check_mockup(GOOD, &["docs/mockups/pill".to_string()], &d);
1195 let all = m.join("\n");
1196 for piece in [
1197 "docs/mockups/pill",
1198 "viewport line",
1199 "artboard.png",
1200 "## Differences from the mockup",
1201 ] {
1202 assert!(all.contains(piece), "{piece} in {all}");
1203 }
1204 }
1205
1206 #[test]
1207 fn a_missing_image_is_refused_even_by_absolute_path() {
1208 let d = folder_with(&["after.png"]);
1209 let text = MOCKUP_GUIDE.replace("(artboard.png)", "(/nonexistent/artboard.png)");
1210 let m = check_mockup(&text, &["docs/mockups/pill".to_string()], &d);
1211 assert!(
1212 m.iter().any(|m| m.contains("/nonexistent/artboard.png")),
1213 "{m:?}"
1214 );
1215 }
1216
1217 #[test]
1218 fn differences_must_be_classified_or_none() {
1219 assert!(differences(&["None"]).is_ok());
1220 assert!(differences(&["- fixed: spacing", "- **deliberate:** a11y copy"]).is_ok());
1221 assert!(differences(&["- spacing is off"])
1222 .unwrap_err()
1223 .contains("1 bullet"));
1224 assert!(
1225 differences(&["- deliberate:"]).is_err(),
1226 "a reason is required"
1227 );
1228 assert!(differences(&[]).is_err());
1229 }
1230
1231 #[test]
1232 fn proofs_pair_by_state_and_accept_the_aliases() {
1233 let text = "    ";
1234 let p = proofs(text);
1235 assert_eq!(p.len(), 2, "{p:?}");
1236 assert_eq!(
1237 (p[0].design.as_str(), p[0].built.as_str()),
1238 ("shots/mockup.png", "live.png")
1239 );
1240 assert_eq!(p[1].state, "-empty");
1241 assert_eq!(
1242 before_image(text).map(|(_, s)| s),
1243 Some("before.png".to_string())
1244 );
1245 }
1246
1247 #[test]
1248 fn the_page_shows_the_pair_full_width_with_an_overlay() {
1249 let html = render(
1250 MOCKUP_GUIDE,
1251 &Page {
1252 label: "app@abcdef0",
1253 commit: "abcdef0123",
1254 url: "http://localhost:5173/",
1255 },
1256 );
1257 assert!(
1258 html.contains("Mockup, direction B"),
1259 "caption names the direction"
1260 );
1261 assert!(html.contains("Built, <code>abcdef0</code>"));
1262 assert!(html.contains("Viewport: 1120px ยท light"));
1263 assert!(html.contains("class=\"overlay\""));
1264 assert!(
1265 html.contains("<a href=\"artboard.png\">"),
1266 "links to the full size"
1267 );
1268 assert_eq!(
1269 html.matches("src=\"artboard.png\"").count(),
1270 1,
1271 "not repeated inline"
1272 );
1273 }
1274
1275 #[test]
1276 fn a_png_width_is_read_from_its_header() {
1277 let d = folder_with(&[]);
1278 let mut png = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13];
1279 png.extend(b"IHDR");
1280 png.extend(1120u32.to_be_bytes());
1281 png.extend(800u32.to_be_bytes());
1282 std::fs::write(d.join("a.png"), &png).unwrap();
1283 assert_eq!(png_width(&d.join("a.png")), Some(1120));
1284 std::fs::write(d.join("b.png"), b"not a png at all, just text").unwrap();
1285 assert_eq!(png_width(&d.join("b.png")), None);
1286 }
1287
1288 #[test]
1289 fn extra_words_are_not_a_note() {
1290 for (heading, name) in [
1291 ("Try it now", "Try it"),
1292 ("Reference material", "Reference"),
1293 ("Try items", "Try it"),
1294 ("Already", "Already checked"),
1295 ] {
1296 assert!(!names_section(heading, name), "{heading:?} is not {name:?}");
1297 }
1298 }
1299
1300 #[test]
1301 fn a_missing_section_is_named() {
1302 let text = GOOD.replace("## Already checked", "## Checked");
1303 let m = check(&text);
1304 assert_eq!(m.len(), 1, "{m:?}");
1305 assert!(m[0].contains("Already checked"));
1306 }
1307
1308 #[test]
1309 fn a_heading_inside_a_fence_is_not_a_section() {
1310 let text = GOOD.replace(
1311 "## Already checked\n- console clean\n",
1312 "```\n## Already checked\n```\n",
1313 );
1314 assert!(check(&text).iter().any(|m| m.contains("Already checked")));
1315 }
1316
1317 #[test]
1318 fn inline_markup_is_escaped_and_unsafe_links_are_text() {
1319 let ctx = Ctx { paired: &[] };
1320 assert_eq!(
1321 inline("<script>x</script>", &ctx),
1322 "<script>x</script>"
1323 );
1324 assert!(!inline("[x](javascript:alert(1))", &ctx).contains("<a"));
1325 assert_eq!(
1326 inline("see http://localhost:1/a.", &ctx),
1327 "see <a href=\"http://localhost:1/a\">http://localhost:1/a</a>."
1328 );
1329 assert_eq!(
1330 inline("**b** `c<`", &ctx),
1331 "<strong>b</strong> <code>c<</code>"
1332 );
1333 }
1334
1335 #[test]
1336 fn steps_nest_their_expectations() {
1337 let html = render(
1338 GOOD,
1339 &Page {
1340 label: "app@abc1234",
1341 commit: "abc",
1342 url: "http://localhost:5173/",
1343 },
1344 );
1345 assert!(html.contains("<ol>\n<li>Open"), "{html}");
1346 assert!(
1347 html.contains("<ul>\n<li>You should see the form."),
1348 "{html}"
1349 );
1350 assert!(html.contains("<div class=\"pair\">"), "{html}");
1351 assert!(
1352 html.contains("<title>app@abc1234 โ duro-app, branch feat/x, plan Settings.</title>"),
1353 "{html}"
1354 );
1355 }
1356
1357 #[test]
1358 fn file_urls_are_encoded() {
1359 assert_eq!(file_url(Path::new("/a b/c.html")), "file:///a%20b/c.html");
1360 assert_eq!(
1361 file_url(Path::new(r"\\?\C:\x\i.html")),
1362 "file:///C:/x/i.html"
1363 );
1364 }
1365}