Repository: kivikakk/comrak (rust)

Issue:
Generation of Header ID's
I suspect I may already know the answer to this question, however as I'm not really that familiar with the different markdown specs so thought I would reach out anyway.

Github automatically creates header id's, for example (`id="user-content-about"`)
```
## About

// Renders

<h2>
  <a href="#about" aria-hidden="true" class="anchor" id="user-content-about">
    ...<svg></svg>
  </a>About
</h2>
```

Im guessing github is using  [kramdown#auto-ids](https://kramdown.gettalong.org/converter/html#auto-ids) which is not not supported in comrak, correct? 

This stems from an [issue](https://github.com/rust-lang/crates.io/issues/1037) on crates.io as we're trying to link headers to the appropriate section within the readme.

Thanks for your time!



Discussion:
GitHub uses [`cmark-gfm`](https://github.com/github/cmark)/[`commonmarker`](https://github.com/gjtorikian/commonmarker); after Markdown is converted to HTML, the HTML is run through a user content stack which e.g. adds user links (like @treiff), issue links, as well as header IDs.

I'll happily add an extension to comrak to do this in a GitHub-compatible matter. I'll let you know via this issue when done, and help integrate it with crates.io.

Patch:
diff --git a/src/entity.rs b/src/entity.rs
--- a/src/entity.rs
+++ b/src/entity.rs
@@ -65,7 +65,7 @@ pub fn unescape(text: &[u8]) -> Option<(Vec<u8>, usize)> {
 }
 
 fn lookup(text: &[u8]) -> Option<&[u8]> {
-    let entity_str = format!("&{};", unsafe {str::from_utf8_unchecked(text) });
+    let entity_str = format!("&{};", unsafe { str::from_utf8_unchecked(text) });
 
     let entity = ENTITIES.iter().find(|e| e.entity == entity_str);
 
diff --git a/src/html.rs b/src/html.rs
--- a/src/html.rs
+++ b/src/html.rs
@@ -1,7 +1,10 @@
 use ctype::isspace;
 use nodes::{TableAlignment, NodeValue, ListType, AstNode};
 use parser::ComrakOptions;
+use regex::Regex;
+use std::borrow::Cow;
 use std::cell::Cell;
+use std::collections::HashSet;
 use std::io::{self, Write};
 
 /// Formats an AST as HTML, modified by the given options.
@@ -42,6 +45,7 @@ impl<'w> Write for WriteWithLast<'w> {
 struct HtmlFormatter<'o> {
     output: &'o mut WriteWithLast<'o>,
     options: &'o ComrakOptions,
+    seen_anchors: HashSet<String>,
 }
 
 const NEEDS_ESCAPED : [bool; 256] = [
@@ -142,6 +146,7 @@ impl<'o> HtmlFormatter<'o> {
         HtmlFormatter {
             options: options,
             output: output,
+            seen_anchors: HashSet::new(),
         }
     }
 
@@ -269,6 +274,19 @@ impl<'o> HtmlFormatter<'o> {
         Ok(())
     }
 
+    fn collect_text<'a>(&self, node: &'a AstNode<'a>, output: &mut Vec<u8>) {
+        match node.data.borrow().value {
+            NodeValue::Text(ref literal) |
+            NodeValue::Code(ref literal) => output.extend_from_slice(literal),
+            NodeValue::LineBreak | NodeValue::SoftBreak => output.push(b' '),
+            _ => {
+                for n in node.children() {
+                    self.collect_text(n, output);
+                }
+            }
+        }
+    }
+
     fn format_node<'a>(&mut self, node: &'a AstNode<'a>, entering: bool) -> io::Result<bool> {
         match node.data.borrow().value {
             NodeValue::Document => (),
@@ -306,9 +324,48 @@ impl<'o> HtmlFormatter<'o> {
                 }
             }
             NodeValue::Heading(ref nch) => {
+                lazy_static! {
+                    static ref REJECTED_CHARS: Regex = Regex::new(r"[^\p{L}\p{M}\p{N}\p{Pc} -]").unwrap();
+                }
+
                 if entering {
                     try!(self.cr());
                     try!(write!(self.output, "<h{}>", nch.level));
+
+                    if let Some(ref prefix) = self.options.ext_header_ids {
+                        let mut text_content = Vec::with_capacity(20);
+                        self.collect_text(node, &mut text_content);
+
+                        let mut id = String::from_utf8(text_content).unwrap();
+                        id = id.to_lowercase();
+                        id = REJECTED_CHARS.replace(&id, "").to_string();
+                        id = id.replace(' ', "-");
+
+                        let mut uniq = 0;
+                        id = loop {
+                            let anchor = if uniq == 0 {
+                                Cow::from(&*id)
+                            } else {
+                                Cow::from(format!("{}-{}", &id, uniq))
+                            };
+
+                            if !self.seen_anchors.contains(&*anchor) {
+                                break anchor.to_string();
+                            }
+
+                            uniq += 1;
+                        };
+
+                        self.seen_anchors.insert(id.clone());
+
+                        try!(write!(
+                            self.output,
+                            "<a href=\"#{}\" aria-hidden=\"true\" class=\"anchor\" id=\"{}{}\"></a>",
+                            id,
+                            prefix,
+                            id
+                        ));
+                    }
                 } else {
                     try!(write!(self.output, "</h{}>\n", nch.level));
                 }
diff --git a/src/main.rs b/src/main.rs
--- a/src/main.rs
+++ b/src/main.rs
@@ -103,6 +103,15 @@ fn main() {
                 .default_value("0")
                 .help("Specify wrap width (0 = nowrap)"),
         )
+        .arg(
+            clap::Arg::with_name("header-ids")
+                .long("header-ids")
+                .takes_value(true)
+                .value_name("PREFIX")
+                .help(
+                    "Use the Comrak header IDs extension, with the given ID prefix",
+                ),
+        )
         .get_matches();
 
     let mut exts = matches.values_of("extension").map_or(
@@ -122,6 +131,7 @@ fn main() {
         ext_autolink: exts.remove("autolink"),
         ext_tasklist: exts.remove("tasklist"),
         ext_superscript: exts.remove("superscript"),
+        ext_header_ids: matches.value_of("header-ids").map(|s| s.to_string()),
     };
 
     assert!(exts.is_empty());
diff --git a/src/parser/mod.rs b/src/parser/mod.rs
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -63,7 +63,7 @@ pub struct Parser<'a, 'o> {
     options: &'o ComrakOptions,
 }
 
-#[derive(Default, Debug, Clone, Copy)]
+#[derive(Default, Debug, Clone)]
 /// Options for both parser and formatter functions.
 pub struct ComrakOptions {
     /// [Soft line breaks](http://spec.commonmark.org/0.27/#soft-line-breaks) in the input
@@ -197,6 +197,17 @@ pub struct ComrakOptions {
     ///            "<p>e = mc<sup>2</sup>.</p>\n");
     /// ```
     pub ext_superscript: bool,
+
+    /// Enables the header IDs Comrak extension.
+    ///
+    /// ```
+    /// # use comrak::{markdown_to_html, ComrakOptions};
+    /// let mut options = ComrakOptions::default();
+    /// options.ext_header_ids = Some("user-content-".to_string());
+    /// assert_eq!(markdown_to_html("# README\n", &options),
+    ///            "<h1><a href=\"#readme\" aria-hidden=\"true\" class=\"anchor\" id=\"user-content-readme\"></a>README</h1>\n");
+    /// ```
+    pub ext_header_ids: Option<String>,
 }
 
 


Test patch:
diff --git a/src/tests.rs b/src/tests.rs
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -515,3 +515,26 @@ fn superscript() {
               concat!("<p>e = mc<sup>2</sup>.</p>\n"),
               |opts| opts.ext_superscript = true);
 }
+
+#[test]
+fn header_ids() {
+    html_opts(
+        concat!(
+            "# Hi.\n",
+            "## Hi 1.\n",
+            "### Hi.\n",
+            "#### Hello.\n",
+            "##### Hi.\n",
+            "###### Hello.\n"
+        ),
+        concat!(
+            "<h1><a href=\"#hi\" aria-hidden=\"true\" class=\"anchor\" id=\"user-content-hi\"></a>Hi.</h1>\n",
+            "<h2><a href=\"#hi-1\" aria-hidden=\"true\" class=\"anchor\" id=\"user-content-hi-1\"></a>Hi 1.</h2>\n",
+            "<h3><a href=\"#hi-2\" aria-hidden=\"true\" class=\"anchor\" id=\"user-content-hi-2\"></a>Hi.</h3>\n",
+            "<h4><a href=\"#hello\" aria-hidden=\"true\" class=\"anchor\" id=\"user-content-hello\"></a>Hello.</h4>\n",
+            "<h5><a href=\"#hi-3\" aria-hidden=\"true\" class=\"anchor\" id=\"user-content-hi-3\"></a>Hi.</h5>\n",
+            "<h6><a href=\"#hello-1\" aria-hidden=\"true\" class=\"anchor\" id=\"user-content-hello-1\"></a>Hello.</h6>\n"
+        ),
+        |opts| opts.ext_header_ids = Some("user-content-".to_owned()),
+    );
+}