mirror of
https://github.com/yeasy/docker_practice.git
synced 2026-08-10 08:27:25 +00:00
ci: gate CJK-punctuation bold regressions with a self-contained emphasis check
check_project_rules.py only checks fences + local links, so a bold marker that CJK punctuation stops from rendering (e.g. `**(配比)**相互交织` — the closer is preceded by punctuation and followed by a letter, so it can't close and the asterisks print literally) would pass this repo's own CI. That class of bug had to be swept out of every book by hand; nothing here was watching for it. Adds check_emphasis.py — a standalone gate (identical across all books) that runs CommonMark's flanking + process-emphasis rules and flags markers that print literally or silently bold the wrong span. The ci.yaml content step now runs it right after check_project_rules.py. Verified clean on this book.
This commit is contained in:
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Check project rules, metadata, and canonical examples
|
||||
run: |
|
||||
python3 check_project_rules.py
|
||||
python3 check_project_rules.py && python3 check_emphasis.py
|
||||
npm test
|
||||
python3 tools/test_examples.py --require-tools
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ Docker -- *_site/
|
||||
# Check scripts
|
||||
check*.py
|
||||
!check_project_rules.py
|
||||
!check_emphasis.py
|
||||
find*.py
|
||||
fix*.py
|
||||
format*.py
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CJK-aware emphasis (bold/italic) renderability check for book repositories.
|
||||
|
||||
CommonMark only treats a run of ``*`` as an emphasis delimiter when it is
|
||||
left/right-flanking. CJK punctuation counts as Unicode punctuation, so a closer
|
||||
like ``**(配比)**相互交织`` is preceded by punctuation and followed by a letter:
|
||||
it cannot close, and the page shows literal asterisks instead of bold. The
|
||||
mirror case (``是**“引号”**``) cannot open. This gate flags such non-rendering
|
||||
emphasis so each book's own CI catches a regression — the per-book
|
||||
``check_project_rules.py`` only checks fences and local links.
|
||||
|
||||
This file is kept byte-for-byte identical across all book repos and mirrors
|
||||
check 6 of the workspace-level ``format_checker.py``. Fix a flagged span by
|
||||
inserting one space on the failing side (which book-rules 1.1 already asks for).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
SKIP_DIRS = {
|
||||
".agent",
|
||||
".git",
|
||||
".github",
|
||||
".mdpress",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".vuepress",
|
||||
"_book",
|
||||
"_site",
|
||||
"__pycache__",
|
||||
"dist",
|
||||
"mcp_cache",
|
||||
"node_modules",
|
||||
"output",
|
||||
}
|
||||
|
||||
FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})(.*)$")
|
||||
PUNCT_CATEGORIES = {"Pc", "Pd", "Pe", "Pf", "Pi", "Po", "Ps"}
|
||||
ASCII_PUNCT = set("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~")
|
||||
CJK_RE = re.compile(r"[㐀-鿿豈-]")
|
||||
ASTERISK_RUN_RE = re.compile(r"\*+")
|
||||
BULLET_RE = re.compile(r"^(\s*(?:>\s*)*)([*+-])(\s+)")
|
||||
|
||||
|
||||
def _fence_match(line: str):
|
||||
return FENCE_RE.match(line)
|
||||
|
||||
|
||||
def _is_in_code_block(lines: list[str], line_idx: int) -> bool:
|
||||
active_char = ""
|
||||
active_len = 0
|
||||
for i in range(line_idx):
|
||||
match = _fence_match(lines[i])
|
||||
if not match:
|
||||
continue
|
||||
marker = match.group(1)
|
||||
char, length = marker[0], len(marker)
|
||||
if active_len == 0:
|
||||
active_char, active_len = char, length
|
||||
elif char == active_char and length >= active_len:
|
||||
active_char, active_len = "", 0
|
||||
return active_len > 0
|
||||
|
||||
|
||||
def _is_in_mermaid_block(lines: list[str], line_idx: int) -> bool:
|
||||
active_char = ""
|
||||
active_len = 0
|
||||
is_mermaid = False
|
||||
for i in range(line_idx):
|
||||
match = _fence_match(lines[i])
|
||||
if not match:
|
||||
continue
|
||||
marker = match.group(1)
|
||||
char, length = marker[0], len(marker)
|
||||
if active_len == 0:
|
||||
active_char, active_len = char, length
|
||||
lang_parts = match.group(2).strip().split(maxsplit=1)
|
||||
is_mermaid = bool(lang_parts) and lang_parts[0].lower() == "mermaid"
|
||||
elif char == active_char and length >= active_len:
|
||||
active_char, active_len = "", 0
|
||||
is_mermaid = False
|
||||
return is_mermaid
|
||||
|
||||
|
||||
def _is_punct(ch) -> bool:
|
||||
return ch is not None and (
|
||||
ch in ASCII_PUNCT or unicodedata.category(ch) in PUNCT_CATEGORIES
|
||||
)
|
||||
|
||||
|
||||
def _is_space(ch) -> bool:
|
||||
return ch is None or ch.isspace()
|
||||
|
||||
|
||||
def _left_flanking(prev_ch, next_ch) -> bool:
|
||||
if _is_space(next_ch):
|
||||
return False
|
||||
return (not _is_punct(next_ch)) or _is_space(prev_ch) or _is_punct(prev_ch)
|
||||
|
||||
|
||||
def _right_flanking(prev_ch, next_ch) -> bool:
|
||||
if _is_space(prev_ch):
|
||||
return False
|
||||
return (not _is_punct(prev_ch)) or _is_space(next_ch) or _is_punct(next_ch)
|
||||
|
||||
|
||||
def _neutralize_code(line: str) -> str:
|
||||
"""Replace asterisks inside inline code / math / HTML with '+', preserving
|
||||
length and character classes. Only asterisks change: the backtick in
|
||||
``**`x`**中文`` is itself punctuation and does affect whether the closer works,
|
||||
so the span must not be blanked wholesale."""
|
||||
|
||||
def repl(match: re.Match) -> str:
|
||||
return match.group(0).replace("*", "+")
|
||||
|
||||
for pattern in (r"`[^`]*`", r"\$\$[^$]*\$\$", r"<[^>]+>"):
|
||||
line = re.sub(pattern, repl, line)
|
||||
return line
|
||||
|
||||
|
||||
def _scan_runs(line: str) -> list[dict]:
|
||||
runs = []
|
||||
for match in ASTERISK_RUN_RE.finditer(line):
|
||||
prev_ch = line[match.start() - 1] if match.start() else None
|
||||
next_ch = line[match.end()] if match.end() < len(line) else None
|
||||
runs.append(
|
||||
{
|
||||
"pos": match.start(),
|
||||
"len": len(match.group(0)),
|
||||
"n": len(match.group(0)),
|
||||
"orig": len(match.group(0)),
|
||||
"prev": prev_ch,
|
||||
"next": next_ch,
|
||||
"open": _left_flanking(prev_ch, next_ch),
|
||||
"close": _right_flanking(prev_ch, next_ch),
|
||||
}
|
||||
)
|
||||
return runs
|
||||
|
||||
|
||||
def _process_emphasis(runs: list[dict]) -> list[dict]:
|
||||
"""Run CommonMark's process-emphasis and return the delimiter runs that were
|
||||
left unpaired (they render as literal asterisks)."""
|
||||
ci = 0
|
||||
while ci < len(runs):
|
||||
closer = runs[ci]
|
||||
if not closer["close"] or closer["n"] == 0:
|
||||
ci += 1
|
||||
continue
|
||||
opener_i = None
|
||||
for oi in range(ci - 1, -1, -1):
|
||||
opener = runs[oi]
|
||||
if opener["n"] == 0 or not opener["open"]:
|
||||
continue
|
||||
if closer["open"] or opener["close"]: # rule of three
|
||||
total = closer["orig"] + opener["orig"]
|
||||
if total % 3 == 0 and not (
|
||||
closer["orig"] % 3 == 0 and opener["orig"] % 3 == 0
|
||||
):
|
||||
continue
|
||||
opener_i = oi
|
||||
break
|
||||
if opener_i is None:
|
||||
ci += 1
|
||||
continue
|
||||
opener = runs[opener_i]
|
||||
used = 2 if (opener["n"] >= 2 and closer["n"] >= 2) else 1
|
||||
opener["n"] -= used
|
||||
closer["n"] -= used
|
||||
for k in range(opener_i + 1, ci):
|
||||
runs[k]["n"] = 0
|
||||
if closer["n"] == 0:
|
||||
ci += 1
|
||||
return [run for run in runs if run["n"] > 0]
|
||||
|
||||
|
||||
def _snippet(line: str, pos: int, width: int = 22) -> str:
|
||||
head = "…" if pos > width else ""
|
||||
tail = "…" if pos + width < len(line) else ""
|
||||
return head + line[max(0, pos - width):pos + width] + tail
|
||||
|
||||
|
||||
def check_emphasis(line: str) -> list[str]:
|
||||
"""Return descriptions of non-renderable emphasis markers on this line."""
|
||||
probe = _neutralize_code(line)
|
||||
probe = BULLET_RE.sub(lambda m: m.group(1) + "-" + m.group(3), probe)
|
||||
if "*" not in probe:
|
||||
return []
|
||||
|
||||
runs = _scan_runs(probe)
|
||||
issues: list[str] = []
|
||||
|
||||
# Rule 1: `**` come in pairs; odd ones should open, even ones should close.
|
||||
strong_runs = [run for run in runs if run["len"] >= 2]
|
||||
if strong_runs and len(strong_runs) % 2 == 0:
|
||||
for i, run in enumerate(strong_runs):
|
||||
if i % 2 == 0 and not run["open"]:
|
||||
why = (
|
||||
"标记内侧有空格"
|
||||
if _is_space(run["next"])
|
||||
else "左邻文字、右邻标点,需在标记前补一个空格"
|
||||
)
|
||||
issues.append(
|
||||
f"加粗开标记无法生效({why}): {_snippet(line, run['pos'])}"
|
||||
)
|
||||
elif i % 2 == 1 and not run["close"]:
|
||||
why = (
|
||||
"标记内侧有空格"
|
||||
if _is_space(run["prev"])
|
||||
else "左邻标点、右邻文字,需在标记后补一个空格"
|
||||
)
|
||||
issues.append(
|
||||
f"加粗闭标记无法生效({why}): {_snippet(line, run['pos'])}"
|
||||
)
|
||||
return issues
|
||||
|
||||
# Rule 2: asterisks left over after pairing render literally.
|
||||
leftovers = _process_emphasis(runs)
|
||||
if len(leftovers) < 2:
|
||||
return issues # a lone asterisk is usually a footnote mark, glob, or times sign
|
||||
has_strong = any(run["n"] >= 2 for run in leftovers)
|
||||
wraps_cjk = CJK_RE.search(line[leftovers[0]["pos"]:leftovers[-1]["pos"]])
|
||||
if has_strong or wraps_cjk:
|
||||
issues.append(f"强调标记无法渲染: {_snippet(line, leftovers[0]['pos'])}")
|
||||
return issues
|
||||
|
||||
|
||||
def iter_markdown_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for path in ROOT.rglob("*.md"):
|
||||
if any(part in SKIP_DIRS for part in path.relative_to(ROOT).parts):
|
||||
continue
|
||||
files.append(path)
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
files = iter_markdown_files()
|
||||
if not files:
|
||||
print("No Markdown files found; refusing to report success.")
|
||||
return 1
|
||||
issues: list[str] = []
|
||||
for path in files:
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8", errors="ignore").split("\n")
|
||||
except OSError as exc:
|
||||
issues.append(f"{path.relative_to(ROOT)}:1: 读取文件失败: {exc}")
|
||||
continue
|
||||
for idx, line in enumerate(lines):
|
||||
if _is_in_code_block(lines, idx) or _is_in_mermaid_block(lines, idx):
|
||||
continue
|
||||
for description in check_emphasis(line):
|
||||
issues.append(f"{path.relative_to(ROOT)}:{idx + 1}: {description}")
|
||||
|
||||
if issues:
|
||||
print("\n".join(issues))
|
||||
print(
|
||||
f"\n{len(issues)} emphasis issue(s) found across {len(files)} "
|
||||
"Markdown files. Fix by adding one space on the failing side "
|
||||
"of the ** markers."
|
||||
)
|
||||
return 1
|
||||
print(f"All {len(files)} Markdown files passed the emphasis check.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user