diff --git a/doc_build/iso_clause_lint.py b/doc_build/iso_clause_lint.py
index d728f79..f05731f 100644
--- a/doc_build/iso_clause_lint.py
+++ b/doc_build/iso_clause_lint.py
@@ -21,6 +21,7 @@
"""
import json
+import re
import subprocess
import sys
from dataclasses import dataclass, field
@@ -81,6 +82,50 @@ def format(self, *, context: int = DEFAULT_CONTEXT_LINES, display_path: Optional
return '\n'.join(lines)
+_HTML_COMMENT_RE = re.compile(r'^\s*\s*$', re.DOTALL)
+
+
+def _is_html_comment_block(block: dict) -> bool:
+ """True if *block* is an HTML comment (possibly wrapped in a sourcepos Div)."""
+ candidates = [block]
+ if block.get("t") == "Div":
+ candidates = block.get("c", [None, []])[1]
+ return all(
+ b.get("t") == "RawBlock"
+ and len(b.get("c", [])) == 2
+ and b["c"][0] == "html"
+ and _HTML_COMMENT_RE.match(b["c"][1])
+ for b in candidates
+ )
+
+
+def _strip_html_comment_lines(
+ lines: List[Tuple[int, str]],
+) -> List[Tuple[int, str]]:
+ """Remove lines that are (part of) an HTML comment.
+
+ Handles single-line ```` and multi-line comments where
+ ```` are on separate lines. Lines that contain both
+ non-comment text and a comment tag are kept (conservative).
+ """
+ result: List[Tuple[int, str]] = []
+ in_comment = False
+ for lineno, text in lines:
+ stripped = text.strip()
+ if not in_comment:
+ if _HTML_COMMENT_RE.match(text):
+ continue
+ if stripped.startswith("" in stripped:
+ in_comment = False
+ continue
+ return result
+
+
# ---------------------------------------------------------------------------
# Core checker
# ---------------------------------------------------------------------------
@@ -145,11 +190,11 @@ def check_file(path: Path) -> List[Violation]:
# The first line after the parent heading is raw_lines[cur_lineno]
# (0-indexed), and the last line before the child heading is
# raw_lines[lineno - 2] (0-indexed).
- body_lines = [
+ body_lines = _strip_html_comment_lines([
(i + 1, raw_lines[i])
for i in range(cur_lineno, lineno - 1)
if i < len(raw_lines) and raw_lines[i].strip()
- ]
+ ])
violations.append(Violation(
file=path,
heading_lineno=cur_lineno,
@@ -170,8 +215,9 @@ def check_file(path: Path) -> List[Violation]:
body_seen = False
else:
- # Any non-heading top-level block is body content.
- if current_heading is not None:
+ # Any non-heading top-level block is body content, unless it
+ # is an HTML comment which is invisible in rendered output.
+ if current_heading is not None and not _is_html_comment_block(block):
body_seen = True
return violations
diff --git a/tests/test_iso_clause_lint.py b/tests/test_iso_clause_lint.py
new file mode 100644
index 0000000..b5048cc
--- /dev/null
+++ b/tests/test_iso_clause_lint.py
@@ -0,0 +1,205 @@
+"""Tests for doc_build.iso_clause_lint — focused on HTML comment handling."""
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from doc_build.iso_clause_lint import (
+ _is_html_comment_block,
+ _strip_html_comment_lines,
+ check_file,
+)
+
+
+class TestIsHtmlCommentBlock(unittest.TestCase):
+ """Unit tests for the _is_html_comment_block helper."""
+
+ def test_single_line_raw_block(self):
+ block = {"t": "RawBlock", "c": ["html", "\n"]}
+ self.assertTrue(_is_html_comment_block(block))
+
+ def test_multi_line_raw_block(self):
+ block = {"t": "RawBlock", "c": ["html", "\n"]}
+ self.assertTrue(_is_html_comment_block(block))
+
+ def test_sourcepos_div_wrapping_comment(self):
+ block = {
+ "t": "Div",
+ "c": [
+ ["", [], [["wrapper", "1"], ["data-pos", "3:1-4:1"]]],
+ [{"t": "RawBlock", "c": ["html", "\n"]}],
+ ],
+ }
+ self.assertTrue(_is_html_comment_block(block))
+
+ def test_non_comment_raw_block(self):
+ block = {"t": "RawBlock", "c": ["html", "
not a comment
"]}
+ self.assertFalse(_is_html_comment_block(block))
+
+ def test_para_block(self):
+ block = {"t": "Para", "c": [{"t": "Str", "c": "text"}]}
+ self.assertFalse(_is_html_comment_block(block))
+
+ def test_div_wrapping_para(self):
+ block = {
+ "t": "Div",
+ "c": [
+ ["", [], []],
+ [{"t": "Para", "c": [{"t": "Str", "c": "text"}]}],
+ ],
+ }
+ self.assertFalse(_is_html_comment_block(block))
+
+ def test_non_html_raw_block(self):
+ block = {"t": "RawBlock", "c": ["latex", "\\newpage"]}
+ self.assertFalse(_is_html_comment_block(block))
+
+
+class TestStripHtmlCommentLines(unittest.TestCase):
+ """Unit tests for _strip_html_comment_lines."""
+
+ def test_single_line_comment_removed(self):
+ lines = [(1, ""), (2, "real text")]
+ self.assertEqual(_strip_html_comment_lines(lines), [(2, "real text")])
+
+ def test_multi_line_comment_removed(self):
+ lines = [
+ (1, ""),
+ (4, "real text"),
+ ]
+ self.assertEqual(_strip_html_comment_lines(lines), [(4, "real text")])
+
+ def test_long_single_line_iso_comment(self):
+ comment = (
+ ""
+ )
+ lines = [(1, comment), (2, "real text")]
+ self.assertEqual(_strip_html_comment_lines(lines), [(2, "real text")])
+
+ def test_no_comments(self):
+ lines = [(1, "first"), (2, "second")]
+ self.assertEqual(_strip_html_comment_lines(lines), lines)
+
+ def test_empty_input(self):
+ self.assertEqual(_strip_html_comment_lines([]), [])
+
+ def test_all_comments(self):
+ lines = [(1, ""), (2, "")]
+ self.assertEqual(_strip_html_comment_lines(lines), [])
+
+ def test_multi_line_comment_between_text(self):
+ lines = [
+ (1, "before"),
+ (2, ""),
+ (5, "after"),
+ ]
+ self.assertEqual(
+ _strip_html_comment_lines(lines),
+ [(1, "before"), (5, "after")],
+ )
+
+ def test_multiple_multi_line_comments(self):
+ lines = [
+ (1, ""),
+ (4, "text"),
+ (5, ""),
+ ]
+ self.assertEqual(_strip_html_comment_lines(lines), [(4, "text")])
+
+
+def _write_md(content: str) -> Path:
+ """Write content to a temp .md file and return its path."""
+ f = tempfile.NamedTemporaryFile(
+ mode="w", suffix=".md", delete=False, encoding="utf-8"
+ )
+ f.write(content)
+ f.close()
+ return Path(f.name)
+
+
+class TestCheckFileHtmlComments(unittest.TestCase):
+ """Integration tests: check_file with HTML comments between headings."""
+
+ def test_single_line_comment_not_flagged(self):
+ path = _write_md(
+ "# Parent\n\n\n\n## Child\n\nText.\n"
+ )
+ violations = check_file(path)
+ self.assertEqual(violations, [])
+
+ def test_multi_line_comment_not_flagged(self):
+ path = _write_md(
+ "# Parent\n\n\n\n## Child\n\nText.\n"
+ )
+ violations = check_file(path)
+ self.assertEqual(violations, [])
+
+ def test_real_body_text_still_flagged(self):
+ path = _write_md(
+ "# Parent\n\nReal body text.\n\n## Child\n\nText.\n"
+ )
+ violations = check_file(path)
+ self.assertEqual(len(violations), 1)
+ self.assertEqual(violations[0].heading_text, "Parent")
+
+ def test_comment_plus_body_text_still_flagged(self):
+ path = _write_md(
+ "# Parent\n\n\n\nReal body text.\n\n## Child\n\nText.\n"
+ )
+ violations = check_file(path)
+ self.assertEqual(len(violations), 1)
+
+ def test_single_line_comment_excluded_from_body_lines(self):
+ path = _write_md(
+ "# Parent\n\n\n\nReal body text.\n\n## Child\n\nText.\n"
+ )
+ violations = check_file(path)
+ self.assertEqual(len(violations), 1)
+ raw_texts = [line for _, line in violations[0].body_lines]
+ for text in raw_texts:
+ self.assertNotIn("\n\nReal body text.\n\n## Child\n\nText.\n"
+ )
+ violations = check_file(path)
+ self.assertEqual(len(violations), 1)
+ raw_texts = [line for _, line in violations[0].body_lines]
+ for text in raw_texts:
+ self.assertNotIn("", text)
+ self.assertNotIn("TODO", text)
+
+ def test_only_comments_between_sibling_headings(self):
+ """Comments between same-level headings should not trigger either."""
+ path = _write_md(
+ "# First\n\n## Sub\n\nText.\n\n\n\n# Second\n\nText.\n"
+ )
+ violations = check_file(path)
+ self.assertEqual(violations, [])
+
+ def test_no_headings(self):
+ path = _write_md("Just some text.\n\n\n")
+ violations = check_file(path)
+ self.assertEqual(violations, [])
+
+ def test_multiple_comments_between_headings(self):
+ path = _write_md(
+ "# Parent\n\n\n\n\n\n## Child\n\nText.\n"
+ )
+ violations = check_file(path)
+ self.assertEqual(violations, [])
+
+
+if __name__ == "__main__":
+ unittest.main()