diff --git a/README.md b/README.md
index 4c8a144b..89143606 100644
--- a/README.md
+++ b/README.md
@@ -74,6 +74,7 @@ error[preamble-order]: preamble header `description` must come after `title`
| `markdown-refs` | ERCs are referenced using ERC-X, while other proposals use EIP-X. |
| `markdown-rel-links` | All URLs in the page are relative. |
| `markdown-req-section` | Required sections are present in the body of the proposal. |
+| `markdown-headings-space` | Headers have a space after the leading '#' characters |
| `preamble-author` | The author header is correctly formatted, and there is at least one GitHub user listed. |
| `preamble-date-created` | The `created` header is a date. |
| `preamble-date-last-call-deadline` | The `last-call-deadline` header is a date. |
diff --git a/docs/markdown-headings-space/index.html b/docs/markdown-headings-space/index.html
new file mode 100644
index 00000000..78377616
--- /dev/null
+++ b/docs/markdown-headings-space/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+ markdown-headings-space
+
+
+
+
+
+
markdown-headings-space
+
+ Markdown headers must have a space after the hash characters.
+
+
+
+
Examples
+
+
error[markdown-headings-space]: Space missing in header
+ |
+5 | ##Banana
+ | ^ space required here
+ |
+6 | ####Mango
+ | ^ space required here
+ |
+
+
+
+
Explanation
+
+
+ markdown-headings-space ensures that all headers
+ have a space after the hash characters so that they are valid.
+
+
+
+ The lack of a space makes headers invalid, and they will not be rendered as such.
+
+
+
+
+
diff --git a/eipw-lint/src/lib.rs b/eipw-lint/src/lib.rs
index 8b24d368..6ec5fc59 100644
--- a/eipw-lint/src/lib.rs
+++ b/eipw-lint/src/lib.rs
@@ -457,6 +457,10 @@ pub fn default_lints_enum() -> impl Iterator {
MarkdownSectionRequired {
sections: markdown::SectionRequired,
},
+ MarkdownHeadingsSpace(markdown::HeadingsSpace),
}
impl DefaultLint
@@ -110,6 +111,7 @@ where
Self::MarkdownRelativeLinks(l) => Box::new(l),
Self::MarkdownSectionOrder { sections } => Box::new(sections),
Self::MarkdownSectionRequired { sections } => Box::new(sections),
+ Self::MarkdownHeadingsSpace(l) => Box::new(l),
}
}
}
@@ -148,6 +150,7 @@ where
Self::MarkdownRelativeLinks(l) => l,
Self::MarkdownSectionOrder { sections } => sections,
Self::MarkdownSectionRequired { sections } => sections,
+ Self::MarkdownHeadingsSpace(l) => l,
}
}
}
@@ -278,6 +281,7 @@ where
Self::MarkdownSectionRequired { sections } => DefaultLint::MarkdownSectionRequired {
sections: markdown::SectionRequired(sections.0.iter().map(AsRef::as_ref).collect()),
},
+ Self::MarkdownHeadingsSpace(l) => DefaultLint::MarkdownHeadingsSpace(l.clone()),
}
}
}
diff --git a/eipw-lint/src/lints/markdown.rs b/eipw-lint/src/lints/markdown.rs
index d9a91117..7f1ca018 100644
--- a/eipw-lint/src/lints/markdown.rs
+++ b/eipw-lint/src/lints/markdown.rs
@@ -4,6 +4,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
+pub mod headings_space;
pub mod html_comments;
pub mod json_schema;
pub mod link_first;
@@ -14,6 +15,7 @@ pub mod relative_links;
pub mod section_order;
pub mod section_required;
+pub use self::headings_space::HeadingsSpace;
pub use self::html_comments::HtmlComments;
pub use self::json_schema::JsonSchema;
pub use self::link_first::LinkFirst;
diff --git a/eipw-lint/src/lints/markdown/headings_space.rs b/eipw-lint/src/lints/markdown/headings_space.rs
new file mode 100644
index 00000000..b72bc12b
--- /dev/null
+++ b/eipw-lint/src/lints/markdown/headings_space.rs
@@ -0,0 +1,86 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+use annotate_snippets::snippet::{Annotation, Slice, Snippet};
+
+use annotate_snippets::snippet::SourceAnnotation;
+use comrak::nodes::{Ast, LineColumn, NodeValue, Sourcepos};
+use regex::Regex;
+
+use crate::lints::{Context, Error, Lint};
+
+use serde::{Deserialize, Serialize};
+
+use std::fmt::Debug;
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct HeadingsSpace;
+
+impl Lint for HeadingsSpace {
+ fn lint<'a>(&self, slug: &'a str, ctx: &Context<'a, '_>) -> Result<(), Error> {
+ // Match for text nodes starting with leading '#' chars (upto 6)
+ // Markdown does not recognise these nodes as valid Headings without the space
+ let heading_pattern = Regex::new("^#{1,6}").unwrap();
+ let invalid_headings: Vec<_> = ctx
+ .body()
+ .descendants()
+ .filter_map(|node| match &*node.data.borrow() {
+ // Collect all matching Text nodes
+ Ast {
+ value: NodeValue::Text(text),
+ sourcepos:
+ Sourcepos {
+ start: LineColumn { column: 1, .. }, // Only match text nodes at the start of the line
+ ..
+ },
+ ..
+ } => {
+ if let Some(matched_text) = heading_pattern.find(text) {
+ let heading_level = matched_text.end();
+ Some((
+ text.clone(),
+ node.data.borrow().sourcepos.start.line,
+ heading_level,
+ ))
+ } else {
+ None
+ }
+ }
+ _ => None,
+ })
+ .collect();
+
+ let slices: Vec<_> = invalid_headings
+ .iter()
+ .map(|(text, line_start, heading_level)| Slice {
+ line_start: *line_start,
+ origin: ctx.origin(),
+ source: text,
+ fold: false,
+ annotations: vec![SourceAnnotation {
+ annotation_type: ctx.annotation_type(),
+ label: "space required here",
+ range: (*heading_level - 1, *heading_level),
+ }],
+ })
+ .collect();
+
+ if !slices.is_empty() {
+ ctx.report(Snippet {
+ title: Some(Annotation {
+ id: Some(slug),
+ annotation_type: ctx.annotation_type(),
+ label: Some("Space missing in header"),
+ }),
+ footer: vec![],
+ slices,
+ opt: Default::default(),
+ })?;
+ }
+
+ Ok(())
+ }
+}
diff --git a/eipw-lint/tests/lint_markdown_headings_space.rs b/eipw-lint/tests/lint_markdown_headings_space.rs
new file mode 100644
index 00000000..37e7618e
--- /dev/null
+++ b/eipw-lint/tests/lint_markdown_headings_space.rs
@@ -0,0 +1,93 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+use eipw_lint::lints::markdown::HeadingsSpace;
+use eipw_lint::reporters::Text;
+use eipw_lint::Linter;
+
+#[tokio::test]
+async fn normal_headings() {
+ let src = r#"---
+header: value1
+---
+
+##Banana
+####Mango
+"#;
+
+ let reports = Linter::>::default()
+ .clear_lints()
+ .deny("markdown-headings-space", HeadingsSpace {})
+ .check_slice(None, src)
+ .run()
+ .await
+ .unwrap()
+ .into_inner();
+
+ assert_eq!(
+ reports,
+ r#"error[markdown-headings-space]: Space missing in header
+ |
+5 | ##Banana
+ | ^ space required here
+ |
+6 | ####Mango
+ | ^ space required here
+ |
+"#
+ );
+}
+
+#[tokio::test]
+async fn abnormal_heading() {
+ let src = r#"---
+header: value1
+---
+
+##B#an#ana
+"#;
+
+ let reports = Linter::>::default()
+ .clear_lints()
+ .deny("markdown-headings-space", HeadingsSpace {})
+ .check_slice(None, src)
+ .run()
+ .await
+ .unwrap()
+ .into_inner();
+
+ assert_eq!(
+ reports,
+ r#"error[markdown-headings-space]: Space missing in header
+ |
+5 | ##B#an#ana
+ | ^ space required here
+ |
+"#
+ );
+}
+
+#[tokio::test]
+async fn not_headings() {
+ let src_str = r#"---
+header: value1
+---
+
+*Hello*#world
+`#world`
+"#;
+
+ let reports = Linter::>::default()
+ .clear_lints()
+ .deny("markdown-headings-space", HeadingsSpace {})
+ .check_slice(None, src_str)
+ .run()
+ .await
+ .unwrap()
+ .into_inner();
+
+ assert_eq!(reports, "");
+}