//! The bundled manual (plans W10-28 and W10-2): Markdown topics from //! `docs/manual/`, compiled into the program (no file access at run time, so //! it works offline and on the web), a topic list, a search box and a small //! Markdown renderer for the subset the topics use: headings, paragraphs, //! bullet and numbered lists, **bold**, *italic*, `code`, code blocks, simple //! tables and links between topics (`[text](topic.md)`). use crate::theme::Tokens; use crate::App; /// One manual page: its file name (the link target) and Markdown text. pub struct Topic { pub file: &'static str, pub text: &'static str, } macro_rules! topic { ($f:literal) => { Topic { file: $f, text: include_str!(concat!("../../../docs/manual/", $f)), } }; } /// Every topic, in the order of the topic list. pub const TOPICS: &[Topic] = &[ topic!("index.md"), topic!("getting-started.md"), topic!("window.md"), topic!("mouse.md"), topic!("keys.md"), topic!("menus.md"), topic!("draw-shapes.md"), topic!("draw-splines.md"), topic!("draw-text-pictures.md"), topic!("select-move.md"), topic!("transform.md"), topic!("points.md"), topic!("compounds.md"), topic!("join-split.md"), topic!("chop.md"), topic!("trim.md"), topic!("tangent-normal.md"), topic!("measure.md"), topic!("libraries.md"), topic!("plot.md"), topic!("attribute-modes.md"), topic!("attributes.md"), topic!("line-styles.md"), topic!("angle-geom.md"), topic!("smart-links.md"), topic!("snap-grid.md"), topic!("view.md"), topic!("files.md"), topic!("export.md"), topic!("print.md"), topic!("preferences.md"), topic!("automation.md"), topic!("help-tools.md"), ]; /// The title of a topic: its first `# ` heading. pub fn title_of(t: &Topic) -> &'static str { t.text .lines() .find_map(|l| l.strip_prefix("# ")) .map(str::trim) .unwrap_or(t.file) } /// The topic a name stands for: the file name (with or without `.md`) or the /// title, ignoring case. pub fn find_topic(name: &str) -> Option { let n = name.trim().to_lowercase(); let n = n.strip_suffix(".md").unwrap_or(&n); TOPICS .iter() .position(|t| t.file.strip_suffix(".md").unwrap_or(t.file) == n) .or_else(|| TOPICS.iter().position(|t| title_of(t).to_lowercase() == n)) } // ---- search ---------------------------------------------------------------- /// A search result: the topic and the first line that matches. pub struct Hit { pub topic: usize, pub snippet: String, } /// Topics whose title or text contain every word of `query` (ignoring case), /// titles first. pub fn search(query: &str) -> Vec { let words: Vec = query.split_whitespace().map(str::to_lowercase).collect(); let Some(first) = words.first() else { return Vec::new(); }; let mut hits = Vec::new(); for (i, t) in TOPICS.iter().enumerate() { let title = title_of(t).to_lowercase(); let text = t.text.to_lowercase(); if !words.iter().all(|w| title.contains(w) || text.contains(w)) { continue; } let line = t .text .lines() .find(|l| l.to_lowercase().contains(first.as_str())) .unwrap_or(""); let snippet: String = line .trim() .trim_start_matches(['#', '-', '*', '|', ' ']) .chars() .filter(|c| !matches!(c, '*' | '`')) .take(110) .collect(); hits.push((words.iter().all(|w| title.contains(w)), Hit { topic: i, snippet })); } hits.sort_by_key(|(in_title, _)| !*in_title); hits.into_iter().map(|(_, h)| h).collect() } // ---- Markdown ---------------------------------------------------------------- #[derive(Debug, Clone, PartialEq)] pub enum Style { Plain, Bold, Italic, Code, Link(String), } #[derive(Debug, Clone, PartialEq)] pub struct Span { pub text: String, pub style: Style, } #[derive(Debug, Clone, PartialEq)] pub enum Block { Heading(u8, Vec), Para(Vec), List { ordered: bool, items: Vec>, }, Code(String), Table { head: Vec>, rows: Vec>>, }, } fn find_seq(chars: &[char], from: usize, pat: &[char]) -> Option { let n = pat.len(); let last = chars.len().checked_sub(n)?; (from..=last).find(|&i| chars.get(i..i + n) == Some(pat)) } fn collect(chars: &[char], a: usize, b: usize) -> String { chars.get(a..b).map(|s| s.iter().collect()).unwrap_or_default() } /// Inline Markdown: `**bold**`, `*italic*`, `` `code` ``, `[text](target)`. pub fn parse_inline(src: &str) -> Vec { let c: Vec = src.chars().collect(); let mut out: Vec = Vec::new(); let mut plain = String::new(); let mut i = 0; let flush = |plain: &mut String, out: &mut Vec| { if !plain.is_empty() { out.push(Span { text: std::mem::take(plain), style: Style::Plain, }); } }; while let Some(&ch) = c.get(i) { let rest = i + 1; match ch { '`' => { if let Some(e) = find_seq(&c, rest, &['`']) { flush(&mut plain, &mut out); out.push(Span { text: collect(&c, rest, e), style: Style::Code, }); i = e + 1; continue; } } '*' if c.get(rest) == Some(&'*') => { if let Some(e) = find_seq(&c, i + 2, &['*', '*']) { flush(&mut plain, &mut out); out.push(Span { text: collect(&c, i + 2, e), style: Style::Bold, }); i = e + 2; continue; } } '*' if c.get(rest).is_some_and(|n| !n.is_whitespace()) => { if let Some(e) = find_seq(&c, rest, &['*']) { flush(&mut plain, &mut out); out.push(Span { text: collect(&c, rest, e), style: Style::Italic, }); i = e + 1; continue; } } '[' => { if let Some(m) = find_seq(&c, rest, &[']', '(']) { if let Some(e) = find_seq(&c, m + 2, &[')']) { flush(&mut plain, &mut out); out.push(Span { text: collect(&c, rest, m), style: Style::Link(collect(&c, m + 2, e)), }); i = e + 1; continue; } } } '\\' if c.get(rest).is_some_and(|n| "*`[]\\|".contains(*n)) => { if let Some(n) = c.get(rest) { plain.push(*n); } i += 2; continue; } _ => {} } plain.push(ch); i += 1; } flush(&mut plain, &mut out); out } fn table_cells(line: &str) -> Vec> { let t = line.trim(); let t = t.strip_prefix('|').unwrap_or(t); let t = t.strip_suffix('|').unwrap_or(t); // `\|` is a bar inside a cell. t.replace("\\|", "\u{1}") .split('|') .map(|c| parse_inline(c.trim().replace('\u{1}', "\\|").as_str())) .collect() } fn is_rule(line: &str) -> bool { let t = line.trim(); t.starts_with('|') && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) } fn list_item(line: &str) -> Option<(bool, &str)> { let t = line.trim_start(); if let Some(r) = t.strip_prefix("- ").or_else(|| t.strip_prefix("* ")) { return Some((false, r)); } let digits = t.chars().take_while(char::is_ascii_digit).count(); if digits > 0 { let r = t.get(digits..)?; return r.strip_prefix(". ").map(|r| (true, r)); } None } /// Parse the Markdown subset into blocks. Never fails: what isn't /// recognised is a paragraph. pub fn parse(src: &str) -> Vec { let mut blocks = Vec::new(); let mut para = String::new(); let mut list: Option<(bool, Vec)> = None; let lines: Vec<&str> = src.lines().collect(); let mut i = 0; macro_rules! flush { () => { if !para.is_empty() { blocks.push(Block::Para(parse_inline(&std::mem::take(&mut para)))); } if let Some((ordered, items)) = list.take() { blocks.push(Block::List { ordered, items: items.iter().map(|s| parse_inline(s)).collect(), }); } }; } while let Some(&line) = lines.get(i) { i += 1; let t = line.trim_end(); if t.trim_start().starts_with("```") { flush!(); let mut code = Vec::new(); while let Some(&l) = lines.get(i) { i += 1; if l.trim_start().starts_with("```") { break; } code.push(l); } blocks.push(Block::Code(code.join("\n"))); } else if t.trim().is_empty() { flush!(); } else if t.trim_start().starts_with("`) is closed /// with ``, and every system still gets the title. #[test] fn system_blocks_are_closed() { use crate::platform::{for_os, Os}; for t in TOPICS { let mut open = false; for line in t.text.lines().map(str::trim) { if line == "" { assert!(open, "{}: end without a block", t.file); open = false; } else if line.starts_with("