Skip to content

Commit 9de5e06

Browse files
committed
feat(binder): add unclosed-html-comments and missing-image-files proactive scopes
1 parent 1e9e423 commit 9de5e06

1 file changed

Lines changed: 126 additions & 0 deletions

File tree

book/cli/commands/validate.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,8 @@ class ValidateCommand:
490490
note="supported callout types, titles, and attributes"),
491491
Scope("callout-title-hygiene", "_run_callout_title_hygiene",
492492
note="callout title presence, generic titles, trailing periods, and @-ref leaks"),
493+
Scope("unclosed-html-comments", "_run_unclosed_html_comments",
494+
note="unclosed <!-- HTML comment tags that silently swallow prose"),
493495
# default=False until vol2 narrative callouts are normalized to the
494496
# same schema vol1 uses; flip to True once `--scope callout-schema`
495497
# is clean on dev for both volumes.
@@ -711,6 +713,8 @@ class ValidateCommand:
711713
Scope("formats", "_run_image_formats"),
712714
Scope("external", "_run_external_images"),
713715
Scope("svg-xml", "_run_svg_wellformedness"),
716+
Scope("missing", "_run_missing_image_files",
717+
note="every local image referenced in QMD must exist on disk"),
714718
],
715719
"json": [
716720
Scope("syntax", "_run_json_syntax"),
@@ -7898,6 +7902,128 @@ def _run_callout_title_hygiene(self, root: Path) -> ValidationRunResult:
78987902
elapsed_ms=int((time.time() - start) * 1000),
78997903
)
79007904

7905+
def _run_unclosed_html_comments(self, root: Path) -> ValidationRunResult:
7906+
"""Flag unclosed <!-- HTML comment tags that silently swallow prose and code."""
7907+
start = time.time()
7908+
files = self._qmd_files(root)
7909+
issues: List[ValidationIssue] = []
7910+
7911+
for file in files:
7912+
text = self._read_text(file)
7913+
lines = text.splitlines()
7914+
in_comment = False
7915+
comment_start_line = 0
7916+
7917+
for idx, line in enumerate(lines, 1):
7918+
pos = 0
7919+
while pos < len(line):
7920+
if not in_comment:
7921+
start_idx = line.find("<!--", pos)
7922+
if start_idx != -1:
7923+
in_comment = True
7924+
comment_start_line = idx
7925+
pos = start_idx + 4
7926+
else:
7927+
break
7928+
else:
7929+
end_idx = line.find("-->", pos)
7930+
if end_idx != -1:
7931+
in_comment = False
7932+
pos = end_idx + 3
7933+
else:
7934+
break
7935+
7936+
if in_comment:
7937+
context = lines[comment_start_line - 1].strip()[:100] if comment_start_line <= len(lines) else ""
7938+
issues.append(
7939+
ValidationIssue(
7940+
file=self._relative_file(file),
7941+
line=comment_start_line,
7942+
code="unclosed_html_comment",
7943+
message=(
7944+
f"Unclosed '<!--' HTML comment starting on line {comment_start_line} "
7945+
f"-- add '-->' to close comment and prevent text from being swallowed"
7946+
),
7947+
severity="error",
7948+
context=context,
7949+
)
7950+
)
7951+
7952+
return ValidationRunResult(
7953+
name="unclosed-html-comments",
7954+
description="Flag unclosed <!-- HTML comment tags",
7955+
files_checked=len(files),
7956+
issues=issues,
7957+
elapsed_ms=int((time.time() - start) * 1000),
7958+
)
7959+
7960+
def _run_missing_image_files(self, root: Path) -> ValidationRunResult:
7961+
"""Flag local image file references in QMD files that do not exist on disk."""
7962+
start = time.time()
7963+
files = self._qmd_files(root)
7964+
issues: List[ValidationIssue] = []
7965+
7966+
md_img_re = re.compile(r"!\[.*?\]\(([^)]+)\)")
7967+
html_img_re = re.compile(r'<img\s+[^>]*src=["\']([^"\']+)["\']')
7968+
7969+
for file in files:
7970+
text = self._read_text(file)
7971+
lines = text.splitlines()
7972+
in_code = False
7973+
for idx, line in enumerate(lines, 1):
7974+
stripped = line.strip()
7975+
if stripped.startswith("```"):
7976+
in_code = not in_code
7977+
continue
7978+
if in_code or stripped.startswith("#|") or stripped.startswith("<!--"):
7979+
continue
7980+
7981+
for m in md_img_re.finditer(line):
7982+
src = m.group(1).strip()
7983+
if src.startswith(("http://", "https://", "data:", "#")):
7984+
continue
7985+
src_clean = src.split()[0].strip('"\'')
7986+
img_path = (file.parent / src_clean).resolve()
7987+
if not img_path.exists():
7988+
context = stripped[:100]
7989+
issues.append(
7990+
ValidationIssue(
7991+
file=self._relative_file(file),
7992+
line=idx,
7993+
code="missing_image_file",
7994+
message=f"Image file '{src_clean}' referenced in QMD does not exist on disk at '{self._relative_file(img_path)}'",
7995+
severity="error",
7996+
context=context,
7997+
)
7998+
)
7999+
8000+
for m in html_img_re.finditer(line):
8001+
src = m.group(1).strip()
8002+
if src.startswith(("http://", "https://", "data:", "#")):
8003+
continue
8004+
src_clean = src.split()[0].strip('"\'')
8005+
img_path = (file.parent / src_clean).resolve()
8006+
if not img_path.exists():
8007+
context = stripped[:100]
8008+
issues.append(
8009+
ValidationIssue(
8010+
file=self._relative_file(file),
8011+
line=idx,
8012+
code="missing_image_file",
8013+
message=f"HTML img src '{src_clean}' referenced in QMD does not exist on disk at '{self._relative_file(img_path)}'",
8014+
severity="error",
8015+
context=context,
8016+
)
8017+
)
8018+
8019+
return ValidationRunResult(
8020+
name="missing-image-files",
8021+
description="Flag local image file references that do not exist on disk",
8022+
files_checked=len(files),
8023+
issues=issues,
8024+
elapsed_ms=int((time.time() - start) * 1000),
8025+
)
8026+
79018027
# Prefixes for the book's custom numbered callouts are declared once, in
79028028
# config/shared/base/custom-numbered-blocks.yml (`classes: <name>: prefix:`).
79038029
# Read them from that file rather than hardcoding a list here, so adding a

0 commit comments

Comments
 (0)