|
129 | 129 | "unknown": 0, |
130 | 130 | } |
131 | 131 |
|
| 132 | +# WCAG AA normal-text threshold. Mermaid classDef/style fill+color pairs below |
| 133 | +# this are rewritten to MERMAID_DEFAULT_* since a fixed hex fill can't adapt to |
| 134 | +# light/dark colorMode on its own. |
| 135 | +MERMAID_CONTRAST_THRESHOLD = 4.5 |
| 136 | +MERMAID_DEFAULT_FILL = "#f0f0f0" |
| 137 | +MERMAID_DEFAULT_STROKE = "#666666" |
| 138 | +MERMAID_DEFAULT_TEXT = "#1a1a1a" |
| 139 | +MERMAID_CONTRAST_SUMMARY = { |
| 140 | + "scanned": 0, |
| 141 | + "fixed": 0, |
| 142 | +} |
| 143 | + |
132 | 144 | MAP_COLUMNS = [ |
133 | 145 | "custom_edit_url", |
134 | 146 | "sidebar_label", |
@@ -1889,6 +1901,111 @@ def _analyze_remote_logo(url): |
1889 | 1901 | return result |
1890 | 1902 |
|
1891 | 1903 |
|
| 1904 | +_MERMAID_FENCE_RE = re.compile(r"```mermaid[ \t]*\n(.*?)```", re.DOTALL) |
| 1905 | +_MERMAID_STYLE_LINE_RE = re.compile(r"^(\s*)(classDef|style)\s+(\S+)\s+(.+?)\s*$") |
| 1906 | + |
| 1907 | + |
| 1908 | +def _parse_mermaid_style_props(props_str): |
| 1909 | + props = {} |
| 1910 | + for part in props_str.split(","): |
| 1911 | + if ":" not in part: |
| 1912 | + continue |
| 1913 | + key, _, value = part.partition(":") |
| 1914 | + props[key.strip().lower()] = value.strip() |
| 1915 | + return props |
| 1916 | + |
| 1917 | + |
| 1918 | +def _rebuild_mermaid_style_props(props_str, updates): |
| 1919 | + rebuilt = [] |
| 1920 | + for part in props_str.split(","): |
| 1921 | + if ":" not in part: |
| 1922 | + rebuilt.append(part) |
| 1923 | + continue |
| 1924 | + key, _, value = part.partition(":") |
| 1925 | + key_lower = key.strip().lower() |
| 1926 | + if key_lower in updates: |
| 1927 | + rebuilt.append(f"{key.strip()}: {updates[key_lower]}") |
| 1928 | + else: |
| 1929 | + rebuilt.append(f"{key.strip()}:{value}") |
| 1930 | + return ",".join(rebuilt) |
| 1931 | + |
| 1932 | + |
| 1933 | +def _fix_mermaid_diagram_contrast_in_text(text): |
| 1934 | + changes = [] |
| 1935 | + |
| 1936 | + def _process_block(match): |
| 1937 | + block = match.group(1) |
| 1938 | + new_lines = [] |
| 1939 | + for line in block.split("\n"): |
| 1940 | + style_match = _MERMAID_STYLE_LINE_RE.match(line) |
| 1941 | + if not style_match: |
| 1942 | + new_lines.append(line) |
| 1943 | + continue |
| 1944 | + |
| 1945 | + indent, keyword, target, props_str = style_match.groups() |
| 1946 | + props = _parse_mermaid_style_props(props_str) |
| 1947 | + fill = props.get("fill") |
| 1948 | + color = props.get("color") |
| 1949 | + if not fill or not color: |
| 1950 | + new_lines.append(line) |
| 1951 | + continue |
| 1952 | + |
| 1953 | + fill_rgb = _parse_css_color(fill) |
| 1954 | + color_rgb = _parse_css_color(color) |
| 1955 | + if not fill_rgb or not color_rgb: |
| 1956 | + new_lines.append(line) |
| 1957 | + continue |
| 1958 | + |
| 1959 | + MERMAID_CONTRAST_SUMMARY["scanned"] += 1 |
| 1960 | + ratio = _contrast_ratio( |
| 1961 | + _relative_luminance(fill_rgb[:3]), |
| 1962 | + _relative_luminance(color_rgb[:3]), |
| 1963 | + ) |
| 1964 | + if ratio >= MERMAID_CONTRAST_THRESHOLD: |
| 1965 | + new_lines.append(line) |
| 1966 | + continue |
| 1967 | + |
| 1968 | + updates = {"fill": MERMAID_DEFAULT_FILL, "color": MERMAID_DEFAULT_TEXT} |
| 1969 | + if "stroke" in props: |
| 1970 | + updates["stroke"] = MERMAID_DEFAULT_STROKE |
| 1971 | + new_lines.append( |
| 1972 | + f"{indent}{keyword} {target} " |
| 1973 | + f"{_rebuild_mermaid_style_props(props_str, updates)}" |
| 1974 | + ) |
| 1975 | + changes.append((keyword, target, fill, color, ratio)) |
| 1976 | + |
| 1977 | + return "```mermaid\n" + "\n".join(new_lines) + "```" |
| 1978 | + |
| 1979 | + new_text = _MERMAID_FENCE_RE.sub(_process_block, text) |
| 1980 | + return new_text, changes |
| 1981 | + |
| 1982 | + |
| 1983 | +def fix_mermaid_diagram_contrast(docs_prefix): |
| 1984 | + for path in glob.glob(f"{docs_prefix}/**/*.mdx", recursive=True): |
| 1985 | + try: |
| 1986 | + with open(path, "r", encoding="utf-8") as fh: |
| 1987 | + original = fh.read() |
| 1988 | + except OSError: |
| 1989 | + continue |
| 1990 | + |
| 1991 | + if "```mermaid" not in original: |
| 1992 | + continue |
| 1993 | + |
| 1994 | + new_text, changes = _fix_mermaid_diagram_contrast_in_text(original) |
| 1995 | + if not changes: |
| 1996 | + continue |
| 1997 | + |
| 1998 | + with open(path, "w", encoding="utf-8") as fh: |
| 1999 | + fh.write(new_text) |
| 2000 | + |
| 2001 | + MERMAID_CONTRAST_SUMMARY["fixed"] += len(changes) |
| 2002 | + for keyword, target, old_fill, old_color, ratio in changes: |
| 2003 | + print( |
| 2004 | + f" {path}: {keyword} {target} contrast {ratio:.2f}:1 " |
| 2005 | + f"(fill {old_fill}, color {old_color}) -> default colorway" |
| 2006 | + ) |
| 2007 | + |
| 2008 | + |
1892 | 2009 | def _set_html_attr(tag, attr_name, attr_value): |
1893 | 2010 | attr_regex = re.compile(rf'\s{re.escape(attr_name)}="[^"]*"') |
1894 | 2011 | if attr_regex.search(tag): |
@@ -3271,6 +3388,13 @@ def get_dir_make_file_and_recurse(directory): |
3271 | 3388 | # Normalize sibling sidebar positions (docs + _category_.json) to avoid UI ordering collisions |
3272 | 3389 | normalize_sidebar_positions_by_parent(DOCS_PREFIX) |
3273 | 3390 |
|
| 3391 | + # Auto-remediate unreadable Mermaid classDef/style color pairs (contrast gate) |
| 3392 | + fix_mermaid_diagram_contrast(DOCS_PREFIX) |
| 3393 | + if MERMAID_CONTRAST_SUMMARY["scanned"] > 0: |
| 3394 | + print("\n### Mermaid diagram contrast analysis ###") |
| 3395 | + print(f"Scanned color pairs: {MERMAID_CONTRAST_SUMMARY['scanned']}") |
| 3396 | + print(f"Fixed (below {MERMAID_CONTRAST_THRESHOLD}:1): {MERMAID_CONTRAST_SUMMARY['fixed']}") |
| 3397 | + |
3274 | 3398 | if LOGO_ANALYSIS_SUMMARY["analyzed"] > 0: |
3275 | 3399 | print("\n### Integration logo contrast analysis ###") |
3276 | 3400 | print(f"Analyzed logos: {LOGO_ANALYSIS_SUMMARY['analyzed']}") |
|
0 commit comments