Skip to main content

dmc_transform/builtin/
autolink_headings.rs

1//! Heading anchor links. See `transformers/autolink-headings.md` for
2//! full docs.
3
4use crate::pipeline::Transformer;
5use crate::visit::{NodeAction, Visitor, walk_root};
6use dmc_diagnostic::Code;
7use dmc_diagnostic::metadata::SourceMeta;
8use dmc_parser::ast::*;
9
10/// Anchor injector for headings.
11#[derive(Default, Debug)]
12pub struct AutolinkHeadings {
13  /// `aria-label` to put on the anchor. Defaults to `"Link to section"`.
14  pub aria_label: Option<String>,
15  /// `className` to put on the anchor. Defaults to `"subheading-anchor"`.
16  pub class_name: Option<String>,
17  /// `className` for the inner `<span>` icon. Defaults to `"icon icon-link"`.
18  pub icon_class_name: Option<String>,
19}
20
21impl AutolinkHeadings {
22  /// Default config matches velite's `rehype-autolink-headings` invocation.
23  pub fn new() -> Self {
24    Self::default()
25  }
26
27  fn aria(&self) -> &str {
28    self.aria_label.as_deref().unwrap_or("Link to section")
29  }
30  fn class(&self) -> &str {
31    self.class_name.as_deref().unwrap_or("subheading-anchor")
32  }
33  fn icon_class(&self) -> &str {
34    self.icon_class_name.as_deref().unwrap_or("icon icon-link")
35  }
36}
37
38impl Transformer for AutolinkHeadings {
39  fn name(&self) -> &str {
40    "autolink-headings"
41  }
42
43  fn transform(
44    &self,
45    doc: &mut Document,
46    _meta: &SourceMeta,
47    _diag_engine: &mut duck_diagnostic::DiagnosticEngine<Code>,
48  ) {
49    let mut v = Apply {
50      aria_label: self.aria().to_string(),
51      class_name: self.class().to_string(),
52      icon_class_name: self.icon_class().to_string(),
53    };
54    walk_root(&mut doc.children, &mut v);
55  }
56}
57
58struct Apply {
59  aria_label: String,
60  class_name: String,
61  icon_class_name: String,
62}
63
64impl Visitor for Apply {
65  fn visit_node(&mut self, node: &mut Node) -> NodeAction {
66    if let Node::Heading(h) = node {
67      let id = h.slug();
68      let span = h.span.clone();
69      // Idempotency: skip if first child is already an `<a>` JsxElement
70      // pointing at this heading's anchor.
71      let already_prepended = matches!(
72        h.children.first(),
73        Some(Node::JsxElement(e))
74          if e.name == "a"
75            && e.attrs.iter().any(|a| a.name == "href" && matches!(&a.value, JsxAttrValue::String(s) if s == &format!("#{}", id)))
76      );
77      if already_prepended {
78        return NodeAction::KeepSkipChildren;
79      }
80      let anchor = build_anchor(&id, &self.aria_label, &self.class_name, &self.icon_class_name, span);
81      h.children.insert(0, anchor);
82      return NodeAction::KeepSkipChildren;
83    }
84    NodeAction::Keep
85  }
86}
87
88/// Build the `<a aria-label class href><span class /></a>` tree.
89fn build_anchor(id: &str, aria: &str, class: &str, icon_class: &str, span: duck_diagnostic::Span) -> Node {
90  let attr = |name: &str, value: &str| JsxAttr {
91    name: name.into(),
92    value: JsxAttrValue::String(value.into()),
93    span: span.clone(),
94  };
95  let icon = Node::JsxSelfClosing(JsxSelfClosing {
96    name: "span".into(),
97    attrs: vec![attr("className", icon_class)],
98    span: span.clone(),
99  });
100  Node::JsxElement(JsxElement {
101    name: "a".into(),
102    attrs: vec![attr("aria-label", aria), attr("className", class), attr("href", &format!("#{}", id))],
103    children: vec![icon],
104    span,
105  })
106}