diff --git a/components/config/src/config/markup.rs b/components/config/src/config/markup.rs index 9ea5b2337..318524b24 100644 --- a/components/config/src/config/markup.rs +++ b/components/config/src/config/markup.rs @@ -36,6 +36,8 @@ pub struct Markdown { pub highlight_themes_css: Vec, /// Whether to render emoji aliases (e.g.: :smile: => 😄) in the markdown files pub render_emoji: bool, + /// CSS class to add to external links + pub external_links_class: Option, /// Whether external links are to be opened in a new tab /// If this is true, a `rel="noopener"` will always automatically be added for security reasons pub external_links_target_blank: bool, @@ -60,6 +62,16 @@ pub struct Markdown { } impl Markdown { + pub fn validate_external_links_class(&self) -> Result<()> { + // Validate external link class doesn't contain quotes which would break HTML and aren't valid in CSS + if let Some(class) = &self.external_links_class { + if class.contains('"') || class.contains('\'') { + bail!("External link class '{}' cannot contain quotes", class) + } + } + Ok(()) + } + /// Gets the configured highlight theme from the THEME_SET or the config's extra_theme_set /// Returns None if the configured highlighting theme is set to use css pub fn get_highlight_theme(&self) -> Option<&Theme> { @@ -168,6 +180,7 @@ impl Markdown { self.external_links_target_blank || self.external_links_no_follow || self.external_links_no_referrer + || self.external_links_class.is_some() } pub fn construct_external_link_tag(&self, url: &str, title: &str) -> String { @@ -175,6 +188,11 @@ impl Markdown { let mut target = "".to_owned(); let title = if title.is_empty() { "".to_owned() } else { format!("title=\"{}\" ", title) }; + let class = self + .external_links_class + .as_ref() + .map_or("".to_owned(), |c| format!("class=\"{}\" ", c)); + if self.external_links_target_blank { // Security risk otherwise rel_opts.push("noopener"); @@ -192,7 +210,7 @@ impl Markdown { format!("rel=\"{}\" ", rel_opts.join(" ")) }; - format!("", rel, target, title, url) + format!("", class, rel, target, title, url) } } @@ -204,6 +222,7 @@ impl Default for Markdown { highlight_theme: DEFAULT_HIGHLIGHT_THEME.to_owned(), highlight_themes_css: Vec::new(), render_emoji: false, + external_links_class: None, external_links_target_blank: false, external_links_no_follow: false, external_links_no_referrer: false, diff --git a/components/config/src/config/mod.rs b/components/config/src/config/mod.rs index a1f1cfaf4..2bc30ecd9 100644 --- a/components/config/src/config/mod.rs +++ b/components/config/src/config/mod.rs @@ -186,6 +186,7 @@ impl Config { // this is the step at which missing extra syntax and highlighting themes are raised as errors config.markdown.init_extra_syntaxes_and_highlight_themes(config_dir)?; + config.markdown.validate_external_links_class()?; Ok(config) } diff --git a/components/markdown/tests/markdown.rs b/components/markdown/tests/markdown.rs index e8cdcd42c..7abfc1b3d 100644 --- a/components/markdown/tests/markdown.rs +++ b/components/markdown/tests/markdown.rs @@ -133,37 +133,63 @@ fn can_use_smart_punctuation() { insta::assert_snapshot!(body); } +#[test] +fn can_use_external_links_class() { + let mut config = Config::default_for_test(); + + // external link class only + config.markdown.external_links_class = Some("external".to_string()); + let body = common::render_with_config("", config.clone()).unwrap().body; + insta::assert_snapshot!("external_link_class", body); + + // internal link (should not add class) + let body = + common::render_with_config("[about](@/pages/about.md)", config.clone()).unwrap().body; + insta::assert_snapshot!("internal_link_no_class", body); + + // reset class, set target blank only + config.markdown.external_links_class = None; + config.markdown.external_links_target_blank = true; + let body = common::render_with_config("", config.clone()).unwrap().body; + insta::assert_snapshot!("external_link_target_blank", body); + + // both class and target blank + config.markdown.external_links_class = Some("external".to_string()); + let body = common::render_with_config("", config).unwrap().body; + insta::assert_snapshot!("external_link_class_and_target_blank", body); +} + #[test] fn can_use_external_links_options() { let mut config = Config::default_for_test(); // no options let body = common::render("").unwrap().body; - insta::assert_snapshot!(body); + insta::assert_snapshot!("external_link_no_options", body); // target blank config.markdown.external_links_target_blank = true; let body = common::render_with_config("", config.clone()).unwrap().body; - insta::assert_snapshot!(body); + insta::assert_snapshot!("external_link_target_blank", body); // no follow config.markdown.external_links_target_blank = false; config.markdown.external_links_no_follow = true; let body = common::render_with_config("", config.clone()).unwrap().body; - insta::assert_snapshot!(body); + insta::assert_snapshot!("external_link_no_follow", body); // no referrer config.markdown.external_links_no_follow = false; config.markdown.external_links_no_referrer = true; let body = common::render_with_config("", config.clone()).unwrap().body; - insta::assert_snapshot!(body); + insta::assert_snapshot!("external_link_no_referrer", body); // all of them config.markdown.external_links_no_follow = true; config.markdown.external_links_target_blank = true; config.markdown.external_links_no_referrer = true; let body = common::render_with_config("", config).unwrap().body; - insta::assert_snapshot!(body); + insta::assert_snapshot!("external_link_all_options", body); } #[test] diff --git a/components/markdown/tests/snapshots/markdown__can_use_external_links_options-5.snap b/components/markdown/tests/snapshots/markdown__external_link_all_options.snap similarity index 66% rename from components/markdown/tests/snapshots/markdown__can_use_external_links_options-5.snap rename to components/markdown/tests/snapshots/markdown__external_link_all_options.snap index 40edd8ddb..0949ee0ea 100644 --- a/components/markdown/tests/snapshots/markdown__can_use_external_links_options-5.snap +++ b/components/markdown/tests/snapshots/markdown__external_link_all_options.snap @@ -1,8 +1,6 @@ --- -source: components/rendering/tests/markdown.rs -assertion_line: 168 +source: components/markdown/tests/markdown.rs expression: body - +snapshot_kind: text ---

https://google.com

- diff --git a/components/markdown/tests/snapshots/markdown__external_link_class.snap b/components/markdown/tests/snapshots/markdown__external_link_class.snap new file mode 100644 index 000000000..ab22c3f7f --- /dev/null +++ b/components/markdown/tests/snapshots/markdown__external_link_class.snap @@ -0,0 +1,6 @@ +--- +source: components/markdown/tests/markdown.rs +expression: body +snapshot_kind: text +--- +

https://google.com

diff --git a/components/markdown/tests/snapshots/markdown__external_link_class_and_target_blank.snap b/components/markdown/tests/snapshots/markdown__external_link_class_and_target_blank.snap new file mode 100644 index 000000000..da6b5de88 --- /dev/null +++ b/components/markdown/tests/snapshots/markdown__external_link_class_and_target_blank.snap @@ -0,0 +1,6 @@ +--- +source: components/markdown/tests/markdown.rs +expression: body +snapshot_kind: text +--- +

https://google.com

diff --git a/components/markdown/tests/snapshots/markdown__can_use_external_links_options-3.snap b/components/markdown/tests/snapshots/markdown__external_link_no_follow.snap similarity index 58% rename from components/markdown/tests/snapshots/markdown__can_use_external_links_options-3.snap rename to components/markdown/tests/snapshots/markdown__external_link_no_follow.snap index 27a53f569..ce7424825 100644 --- a/components/markdown/tests/snapshots/markdown__can_use_external_links_options-3.snap +++ b/components/markdown/tests/snapshots/markdown__external_link_no_follow.snap @@ -1,8 +1,6 @@ --- -source: components/rendering/tests/markdown.rs -assertion_line: 155 +source: components/markdown/tests/markdown.rs expression: body - +snapshot_kind: text ---

https://google.com

- diff --git a/components/markdown/tests/snapshots/markdown__can_use_external_links_options.snap b/components/markdown/tests/snapshots/markdown__external_link_no_options.snap similarity index 54% rename from components/markdown/tests/snapshots/markdown__can_use_external_links_options.snap rename to components/markdown/tests/snapshots/markdown__external_link_no_options.snap index 4f539aa00..b184b12c3 100644 --- a/components/markdown/tests/snapshots/markdown__can_use_external_links_options.snap +++ b/components/markdown/tests/snapshots/markdown__external_link_no_options.snap @@ -1,8 +1,6 @@ --- -source: components/rendering/tests/markdown.rs -assertion_line: 144 +source: components/markdown/tests/markdown.rs expression: body - +snapshot_kind: text ---

https://google.com

- diff --git a/components/markdown/tests/snapshots/markdown__can_use_external_links_options-4.snap b/components/markdown/tests/snapshots/markdown__external_link_no_referrer.snap similarity index 59% rename from components/markdown/tests/snapshots/markdown__can_use_external_links_options-4.snap rename to components/markdown/tests/snapshots/markdown__external_link_no_referrer.snap index ef73ab0bc..78e9bfbab 100644 --- a/components/markdown/tests/snapshots/markdown__can_use_external_links_options-4.snap +++ b/components/markdown/tests/snapshots/markdown__external_link_no_referrer.snap @@ -1,8 +1,6 @@ --- -source: components/rendering/tests/markdown.rs -assertion_line: 161 +source: components/markdown/tests/markdown.rs expression: body - +snapshot_kind: text ---

https://google.com

- diff --git a/components/markdown/tests/snapshots/markdown__can_use_external_links_options-2.snap b/components/markdown/tests/snapshots/markdown__external_link_target_blank.snap similarity index 62% rename from components/markdown/tests/snapshots/markdown__can_use_external_links_options-2.snap rename to components/markdown/tests/snapshots/markdown__external_link_target_blank.snap index ae1b79e73..16af5ca41 100644 --- a/components/markdown/tests/snapshots/markdown__can_use_external_links_options-2.snap +++ b/components/markdown/tests/snapshots/markdown__external_link_target_blank.snap @@ -1,8 +1,6 @@ --- -source: components/rendering/tests/markdown.rs -assertion_line: 149 +source: components/markdown/tests/markdown.rs expression: body - +snapshot_kind: text ---

https://google.com

- diff --git a/components/markdown/tests/snapshots/markdown__internal_link_no_class.snap b/components/markdown/tests/snapshots/markdown__internal_link_no_class.snap new file mode 100644 index 000000000..1d1e334d5 --- /dev/null +++ b/components/markdown/tests/snapshots/markdown__internal_link_no_class.snap @@ -0,0 +1,6 @@ +--- +source: components/markdown/tests/markdown.rs +expression: body +snapshot_kind: text +--- +

about

diff --git a/components/markdown/tests/snapshots/markdown__internal_link_without_class.snap.new b/components/markdown/tests/snapshots/markdown__internal_link_without_class.snap.new new file mode 100644 index 000000000..02f5f43ea --- /dev/null +++ b/components/markdown/tests/snapshots/markdown__internal_link_without_class.snap.new @@ -0,0 +1,6 @@ +--- +source: components/markdown/tests/markdown.rs +assertion_line: 148 +expression: body +--- +

about

diff --git a/docs/content/documentation/getting-started/configuration.md b/docs/content/documentation/getting-started/configuration.md index 0591b021d..5b83712db 100644 --- a/docs/content/documentation/getting-started/configuration.md +++ b/docs/content/documentation/getting-started/configuration.md @@ -128,6 +128,9 @@ highlight_theme = "base16-ocean-dark" # Unicode emoji equivalent in the rendered Markdown files. (e.g.: :smile: => 😄) render_emoji = false +# CSS class to add to external links (e.g. "external-link") +external_links_class = + # Whether external links are to be opened in a new tab # If this is true, a `rel="noopener"` will always automatically be added for security reasons external_links_target_blank = false