Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:02:05.285Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

markdown.rs

406 lines · 14.3 KB · rust
1use std::fmt::{Display, Formatter};
2
3/// Generates a URL-friendly slug from heading text (e.g. "Hello World" → "hello-world").
4pub fn generate_heading_slug(text: &str) -> String {
5    text.trim()
6        .chars()
7        .filter_map(|c| {
8            if c.is_alphanumeric() || c == '-' || c == '_' {
9                Some(c.to_lowercase().next().unwrap_or(c))
10            } else if c == ' ' {
11                Some('-')
12            } else {
13                None
14            }
15        })
16        .collect()
17}
18
19/// Returns true if the URL starts with a URI scheme (RFC 3986 §3.1).
20fn has_uri_scheme(url: &str) -> bool {
21    let mut chars = url.chars();
22    match chars.next() {
23        Some(c) if c.is_ascii_alphabetic() => {}
24        _ => return false,
25    }
26    for c in chars {
27        if c == ':' {
28            return true;
29        }
30        if !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
31            return false;
32        }
33    }
34    false
35}
36
37/// Splits a relative URL into its path and `#fragment` parts.
38/// Absolute URLs are returned as-is with no fragment.
39pub fn split_local_url_fragment(url: &str) -> (&str, Option<&str>) {
40    if has_uri_scheme(url) {
41        return (url, None);
42    }
43    match url.find('#') {
44        Some(pos) => {
45            let path = &url[..pos];
46            let fragment = &url[pos + 1..];
47            (
48                path,
49                if fragment.is_empty() {
50                    None
51                } else {
52                    Some(fragment)
53                },
54            )
55        }
56        None => (url, None),
57    }
58}
59
60pub fn source_position_from_fragment(fragment: &str) -> Option<(u32, u32)> {
61    let fragment = fragment.strip_prefix('L').unwrap_or(fragment);
62    let (line, column) = match fragment.split_once([',', ':']) {
63        Some((line, column)) => (line, Some(column)),
64        None => (
65            fragment.split_once('-').map_or(fragment, |(line, _)| line),
66            None,
67        ),
68    };
69    let line = line.parse::<u32>().ok()?.checked_sub(1)?;
70    let column = column
71        .and_then(|column| column.parse::<u32>().ok())
72        .and_then(|column| column.checked_sub(1))
73        .unwrap_or(0);
74    Some((line, column))
75}
76
77/// Indicates that the wrapped `String` is markdown text.
78#[derive(Debug, Clone)]
79pub struct MarkdownString(pub String);
80
81impl Display for MarkdownString {
82    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
83        write!(f, "{}", self.0)
84    }
85}
86
87/// Escapes markdown special characters in markdown text blocks. Markdown code blocks follow
88/// different rules and `MarkdownInlineCode` or `MarkdownCodeBlock` should be used in that case.
89///
90/// Also escapes the following markdown extensions:
91///
92/// * `^` for superscripts
93/// * `$` for inline math
94/// * `~` for strikethrough
95///
96/// Escape of some characters is unnecessary, because while they are involved in markdown syntax,
97/// the other characters involved are escaped:
98///
99/// * `!`, `]`, `(`, and `)` are used in link syntax, but `[` is escaped so these are parsed as
100///   plaintext.
101///
102/// * `;` is used in HTML entity syntax, but `&` is escaped, so they are parsed as plaintext.
103///
104/// TODO: There is one escape this doesn't do currently. Period after numbers at the start of the
105/// line (`[0-9]*\.`) should also be escaped to avoid it being interpreted as a list item.
106pub struct MarkdownEscaped<'a>(pub &'a str);
107
108/// Implements `Display` to format markdown inline code (wrapped in backticks), handling code that
109/// contains backticks and spaces. All whitespace is treated as a single space character. For text
110/// that does not contain whitespace other than ' ', this escaping roundtrips through
111/// pulldown-cmark.
112///
113/// When used in tables, `|` should be escaped like `\|` in the text provided to this function.
114pub struct MarkdownInlineCode<'a>(pub &'a str);
115
116/// Implements `Display` to format markdown code blocks, wrapped in 3 or more backticks as needed.
117pub struct MarkdownCodeBlock<'a> {
118    pub tag: &'a str,
119    pub text: &'a str,
120}
121
122impl Display for MarkdownEscaped<'_> {
123    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
124        let mut start_of_unescaped = None;
125        for (ix, c) in self.0.char_indices() {
126            match c {
127                // Always escaped.
128                '\\' | '`' | '*' | '_' | '[' | '^' | '$' | '~' | '&' |
129                // TODO: these only need to be escaped when they are the first non-whitespace
130                // character of the line of a block. There should probably be both an `escape_block`
131                // which does this and an `escape_inline` method which does not escape these.
132                '#' | '+' | '=' | '-' => {
133                    match start_of_unescaped {
134                        None => {}
135                        Some(start_of_unescaped) => {
136                            write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
137                        }
138                    }
139                    write!(formatter, "\\")?;
140                    // Can include this char in the "unescaped" text since a
141                    // backslash was just emitted.
142                    start_of_unescaped = Some(ix);
143                }
144                // Escaped since `<` is used in opening HTML tags. `&lt;` is used since Markdown
145                // supports HTML entities, and this allows the text to be used directly in HTML.
146                '<' => {
147                    match start_of_unescaped {
148                        None => {}
149                        Some(start_of_unescaped) => {
150                            write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
151                        }
152                    }
153                    write!(formatter, "&lt;")?;
154                    start_of_unescaped = None;
155                }
156                // Escaped since `>` is used for blockquotes. `&gt;` is used since Markdown supports
157                // HTML entities, and this allows the text to be used directly in HTML.
158                '>' => {
159                    match start_of_unescaped {
160                        None => {}
161                        Some(start_of_unescaped) => {
162                            write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
163                        }
164                    }
165                    write!(formatter, "&gt;")?;
166                    start_of_unescaped = None;
167                }
168                _ => {
169                    if start_of_unescaped.is_none() {
170                        start_of_unescaped = Some(ix);
171                    }
172                }
173            }
174        }
175        if let Some(start_of_unescaped) = start_of_unescaped {
176            write!(formatter, "{}", &self.0[start_of_unescaped..])?;
177        }
178        Ok(())
179    }
180}
181
182impl Display for MarkdownInlineCode<'_> {
183    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
184        // Apache License 2.0, same as this crate.
185        //
186        // Copied from `pulldown-cmark-to-cmark-20.0.0` with modifications:
187        //
188        // * Handling of all whitespace. pulldown-cmark-to-cmark is anticipating
189        // `Code` events parsed by pulldown-cmark.
190        //
191        // https://github.com/Byron/pulldown-cmark-to-cmark/blob/3c850de2d3d1d79f19ca5f375e1089a653cf3ff7/src/lib.rs#L290
192
193        let mut all_whitespace = true;
194        let text = self
195            .0
196            .chars()
197            .map(|c| {
198                if c.is_whitespace() {
199                    ' '
200                } else {
201                    all_whitespace = false;
202                    c
203                }
204            })
205            .collect::<String>();
206
207        // When inline code has leading and trailing ' ' characters, additional space is needed
208        // to escape it, unless all characters are space.
209        if all_whitespace {
210            write!(formatter, "`{text}`")
211        } else {
212            // More backticks are needed to delimit the inline code than the maximum number of
213            // backticks in a consecutive run.
214            let backticks = "`".repeat(count_max_consecutive_chars(&text, '`') + 1);
215            let space = match text.as_bytes() {
216                &[b'`', ..] | &[.., b'`'] => " ", // Space needed to separate backtick.
217                &[b' ', .., b' '] => " ",         // Space needed to escape inner space.
218                _ => "",                          // No space needed.
219            };
220            write!(formatter, "{backticks}{space}{text}{space}{backticks}")
221        }
222    }
223}
224
225impl Display for MarkdownCodeBlock<'_> {
226    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
227        let tag = self.tag;
228        let text = self.text;
229        let backticks = "`".repeat(3.max(count_max_consecutive_chars(text, '`') + 1));
230        write!(formatter, "{backticks}{tag}\n{text}\n{backticks}\n")
231    }
232}
233
234// Copied from `pulldown-cmark-to-cmark-20.0.0` with changed names.
235// https://github.com/Byron/pulldown-cmark-to-cmark/blob/3c850de2d3d1d79f19ca5f375e1089a653cf3ff7/src/lib.rs#L1063
236// Apache License 2.0, same as this code.
237fn count_max_consecutive_chars(text: &str, search: char) -> usize {
238    let mut in_search_chars = false;
239    let mut max_count = 0;
240    let mut cur_count = 0;
241
242    for ch in text.chars() {
243        if ch == search {
244            cur_count += 1;
245            in_search_chars = true;
246        } else if in_search_chars {
247            max_count = max_count.max(cur_count);
248            cur_count = 0;
249            in_search_chars = false;
250        }
251    }
252    max_count.max(cur_count)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_markdown_escaped() {
261        let input = r#"
262        # Heading
263
264        Another heading
265        ===
266
267        Another heading variant
268        ---
269
270        Paragraph with [link](https://example.com) and `code`, *emphasis*, and ~strikethrough~.
271
272        ```
273        code block
274        ```
275
276        List with varying leaders:
277          - Item 1
278          * Item 2
279          + Item 3
280
281        Some math:  $`\sqrt{3x-1}+(1+x)^2`$
282
283        HTML entity: &nbsp;
284        "#;
285
286        let expected = r#"
287        \# Heading
288
289        Another heading
290        \=\=\=
291
292        Another heading variant
293        \-\-\-
294
295        Paragraph with \[link](https://example.com) and \`code\`, \*emphasis\*, and \~strikethrough\~.
296
297        \`\`\`
298        code block
299        \`\`\`
300
301        List with varying leaders:
302          \- Item 1
303          \* Item 2
304          \+ Item 3
305
306        Some math:  \$\`\\sqrt{3x\-1}\+(1\+x)\^2\`\$
307
308        HTML entity: \&nbsp;
309        "#;
310
311        assert_eq!(MarkdownEscaped(input).to_string(), expected);
312    }
313
314    #[test]
315    fn test_markdown_inline_code() {
316        assert_eq!(MarkdownInlineCode(" ").to_string(), "` `");
317        assert_eq!(MarkdownInlineCode("text").to_string(), "`text`");
318        assert_eq!(MarkdownInlineCode("text ").to_string(), "`text `");
319        assert_eq!(MarkdownInlineCode(" text ").to_string(), "`  text  `");
320        assert_eq!(MarkdownInlineCode("`").to_string(), "`` ` ``");
321        assert_eq!(MarkdownInlineCode("``").to_string(), "``` `` ```");
322        assert_eq!(MarkdownInlineCode("`text`").to_string(), "`` `text` ``");
323        assert_eq!(
324            MarkdownInlineCode("some `text` no leading or trailing backticks").to_string(),
325            "``some `text` no leading or trailing backticks``"
326        );
327    }
328
329    #[test]
330    fn test_count_max_consecutive_chars() {
331        assert_eq!(
332            count_max_consecutive_chars("``a```b``", '`'),
333            3,
334            "the highest seen consecutive segment of backticks counts"
335        );
336        assert_eq!(
337            count_max_consecutive_chars("```a``b`", '`'),
338            3,
339            "it can't be downgraded later"
340        );
341    }
342
343    #[test]
344    fn test_split_local_url_fragment() {
345        assert_eq!(split_local_url_fragment("#heading"), ("", Some("heading")));
346        assert_eq!(
347            split_local_url_fragment("./file.md#heading"),
348            ("./file.md", Some("heading"))
349        );
350        assert_eq!(split_local_url_fragment("./file.md"), ("./file.md", None));
351        assert_eq!(
352            split_local_url_fragment("https://example.com#frag"),
353            ("https://example.com#frag", None)
354        );
355        assert_eq!(
356            split_local_url_fragment("mailto:user@example.com"),
357            ("mailto:user@example.com", None)
358        );
359        assert_eq!(split_local_url_fragment("#"), ("", None));
360        assert_eq!(
361            split_local_url_fragment("../other.md#section"),
362            ("../other.md", Some("section"))
363        );
364        assert_eq!(
365            split_local_url_fragment("123:not-a-scheme#frag"),
366            ("123:not-a-scheme", Some("frag"))
367        );
368    }
369
370    #[test]
371    fn test_source_position_from_fragment() {
372        assert_eq!(source_position_from_fragment("9,16"), Some((8, 15)));
373        assert_eq!(source_position_from_fragment("33,33"), Some((32, 32)));
374        assert_eq!(source_position_from_fragment("L42"), Some((41, 0)));
375        assert_eq!(source_position_from_fragment("L42:7"), Some((41, 6)));
376        assert_eq!(source_position_from_fragment("5"), Some((4, 0)));
377        assert_eq!(source_position_from_fragment("L10-L20"), Some((9, 0)));
378        assert_eq!(source_position_from_fragment("heading"), None);
379        assert_eq!(source_position_from_fragment("0,0"), None);
380    }
381
382    #[test]
383    fn test_generate_heading_slug() {
384        assert_eq!(generate_heading_slug("Hello World"), "hello-world");
385        assert_eq!(generate_heading_slug("Hello  World"), "hello--world");
386        assert_eq!(generate_heading_slug("Hello-World"), "hello-world");
387        assert_eq!(
388            generate_heading_slug("Some **bold** text"),
389            "some-bold-text"
390        );
391        assert_eq!(generate_heading_slug("Let's try with Ü"), "lets-try-with-ü");
392        assert_eq!(
393            generate_heading_slug("heading with 123 numbers"),
394            "heading-with-123-numbers"
395        );
396        assert_eq!(
397            generate_heading_slug("What about (parens)?"),
398            "what-about-parens"
399        );
400        assert_eq!(
401            generate_heading_slug("  leading spaces  "),
402            "leading-spaces"
403        );
404    }
405}
406
Served at tenant.openagents/omega Member data and write actions are omitted.