@@ -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