#!/usr/bin/env python3 """ Repo-wide formatter for Markdown/MDX docs. Enforces: - Indentation: convert tabs to four spaces outside code blocks; list indents normalized to multiples of four. - Newlines: ensure a single blank line before and after headings, images, and MDX components like Step/Accordion. - Wrapping: wrap plain text lines to 120 chars (skips code blocks and risky lines). Usage: python scripts/format_docs.py --check # show files that would change python scripts/format_docs.py --write # rewrite files in-place """ from __future__ import annotations import argparse import os import shutil import subprocess import re import sys from typing import List INDENT_SPACES = 2 MD_EXTS = {".md", ".mdx", ".markdown"} def iter_doc_files(root: str) -> List[str]: files: List[str] = [] for base, _dirs, fnames in os.walk(root): # Skip common build/output dirs if present parts = set(base.split(os.sep)) if {"node_modules", ".git", ".next", "dist", "build", "archive"} & parts: continue for fn in fnames: _, ext = os.path.splitext(fn) if ext.lower() in MD_EXTS: files.append(os.path.join(base, fn)) return files FENCE_RE = re.compile(r"^\s*(```|~~~)") HEADING_RE = re.compile(r"^\s{0,3}#{1,6} \S") HR_RE = re.compile(r"^\s{0,3}(-{3,}|_{3,}|\*{3,})\s*$") IMAGE_RE = re.compile(r"^\s*!\[[^\]]*\]\([^)]*\)") LIST_RE = re.compile(r"^(?P\s*)(?P(?:[-+*]|\d+[.)]))\s+") TABLE_RE = re.compile(r"\|.*\|") BAD_NUM_LIST_RE = re.compile(r"^\s*\d+[.)](\S)") # missing space after 1. or 1) ANY_NUM_LIST_RE = re.compile(r"^\s*\d+[.)](?:\s|$)") # any numbered list marker BLOCK_COMPONENT_PREFIXES = ( # Container-level components that should be surrounded by blank lines " bool: return bool(FENCE_RE.match(line)) def is_heading(line: str) -> bool: return bool(HEADING_RE.match(line)) def is_hr(line: str) -> bool: return bool(HR_RE.match(line)) def is_image(line: str) -> bool: return bool(IMAGE_RE.match(line.strip())) def is_block_component(line: str) -> bool: s = line.lstrip() return any(s.startswith(p) for p in BLOCK_COMPONENT_PREFIXES) def normalize_indent(line: str) -> str: # Replace tabs with four spaces only at leading indentation if not line: return line # Split leading whitespace leading = len(line) - len(line.lstrip("\t ")) prefix = line[:leading] suffix = line[leading:] prefix = prefix.replace("\t", " " * INDENT_SPACES) # For list items, ensure indent is a multiple of four spaces m = LIST_RE.match(prefix + suffix) if m: indent = m.group("indent") # Only spaces in indent by now spaces = len(indent) if spaces % INDENT_SPACES != 0: indent = " " * (spaces + (INDENT_SPACES - (spaces % INDENT_SPACES))) # Reconstruct with normalized indent and original rest marker = m.group("marker") rest = (prefix + suffix)[m.end():] return f"{indent}{marker} {rest}" return prefix + suffix def merge_excess_blank_lines(lines: List[str]) -> List[str]: out: List[str] = [] blank = False for ln in lines: if ln.strip() == "": if not blank: out.append("") blank = True else: out.append(ln) blank = False return out def ensure_blank_lines_around_blocks(lines: List[str]) -> List[str]: out: List[str] = [] in_code = False container_stack: List[str] = [] n = len(lines) i = 0 while i < n: line = lines[i] if is_code_fence(line): # Treat code fences like advisory blocks w.r.t. spacing if not in_code: # Opening fence: ensure a blank line before, but none immediately after # Suppress the blank line if previous non-empty line is an immediate child open (Step/AccordionItem/Card) suppress_before = False if len(out) > 0: # Find previous non-empty output line k = len(out) - 1 while k >= 0 and out[k].strip() == "": k -= 1 if k >= 0: prev = out[k].lstrip() if prev.startswith(" 0 and out[-1].strip() != "" and not suppress_before: out.append("") out.append(line) in_code = True i += 1 # Remove any immediate blank lines after the opening fence while i < n and lines[i].strip() == "": i += 1 continue else: # Closing fence: no blank immediately before, add one after (unless next is a closing tag) if len(out) > 0 and out[-1].strip() == "": out.pop() out.append(line) in_code = False # Look ahead to next non-empty original line j = i + 1 while j < n and lines[j].strip() == "": j += 1 next_is_closing_tag = j < n and lines[j].lstrip().startswith(" bool: s = l.lstrip() return ( s.startswith(" bool: s = l.lstrip() return ( s.startswith(" ... s_line_full = line.lstrip() m_single_adv = None for _name in ("Warning", "Info", "Tip", "Note"): if s_line_full.startswith(f"<{_name}") and f"" in s_line_full: m_single_adv = _name break if m_single_adv: # Check previous non-empty line for parent exception prev_non_empty = None for k in range(len(out) - 1, -1, -1): if out[k].strip() != "": prev_non_empty = out[k] break prev_is_parent = False if prev_non_empty is not None: p = prev_non_empty.lstrip() if p.startswith(" 0 and out[-1].strip() != "" and not prev_is_parent: out.append("") # If there is an existing blank line before and we're directly after an opening parent, remove it if prev_is_parent and len(out) > 0 and out[-1].strip() == "": out.pop() out.append(line) # Look ahead to next non-empty line; suppress blank if it's a closing tag j = i + 1 while j < n and lines[j].strip() == "": j += 1 next_is_closing_tag = False if j < n: next_is_closing_tag = lines[j].lstrip().startswith(" 0 and out[-1].strip() != "" and not (is_advisory and prev_is_parent): out.append("") # If advisory follows an opening parent, remove any pre-existing blank line if is_advisory and prev_is_parent and len(out) > 0 and out[-1].strip() == "": out.pop() out.append(line) # Track which container opened name = s_line[1:].split(None, 1)[0].rstrip(">/") container_stack.append(name) i += 1 # Remove any immediate blank lines after the opening tag while i < n and lines[i].strip() == "": i += 1 continue if is_close_container(line): # Remove any immediate blank line before the closing tag if len(out) > 0 and out[-1].strip() == "": out.pop() out.append(line) # Pop matching container if present and detect if it's advisory closed_name = None if container_stack: closed_name = container_stack.pop() # Advisory blocks should normally be followed by a blank line s_line = line.lstrip() is_advisory_close = ( (closed_name in {"Warning", "Info", "Tip", "Note"}) or s_line.startswith(" inside_frame = bool(container_stack and container_stack[-1] == "Frame") is_markdown_img = is_image(line) is_mdx_image = line.lstrip().startswith(" 0 and out[-1].strip() != "": prev_non_empty = None for k in range(len(out) - 1, -1, -1): if out[k].strip() != "": prev_non_empty = out[k].lstrip() break suppress_before = False if prev_non_empty is not None: if prev_non_empty.startswith("\w+)(\s|>)") CLOSE_TAG_RE = re.compile(r"^\s*\w+)\s*>") def normalize_steps_indentation(lines: List[str]) -> List[str]: """Normalize indentation and spacing for group containers and their items. Supports: - with child - with child - with child - with child Rules: - Blank line before container open and after container close. - Inside container: child open/close aligned at container indent + INDENT_SPACES. - Content inside child is indented one more level. - Remove blank lines immediately after child open and before child close. - Ensure exactly one blank line between sibling children; none before container close. """ out: List[str] = [] in_code = False # Stack of (name, indent, managed) stack: List[tuple[str, int, bool]] = [] # Group to child mapping children_for = { "Steps": {"Step"}, "Accordion": {"AccordionItem"}, "AccordionGroup": {"Accordion"}, "Columns": {"Card"}, "CardGroup": {"Card"}, "Tabs": {"Tab"}, } SELF_CLOSING_RE = re.compile(r"^\s*<(?P\w+)(?:[^>]*)/?>\s*$") i = 0 n = len(lines) while i < n: line = lines[i] # Handle code fences if is_code_fence(line): in_code = not in_code out.append(line) i += 1 continue if in_code: out.append(line) i += 1 continue stripped = line.lstrip(" ") leading = len(line) - len(stripped) # Detect opening/closing tags m_open = OPEN_TAG_RE.match(line) m_close = CLOSE_TAG_RE.match(line) # Opening group containers if m_open and m_open.group("name") in children_for: # If nested inside a managed child (e.g., /), align the container # as a content line inside that child. desired_leading = leading if stack: top_name, top_indent, _top_managed = stack[-1] # If the immediate parent is not a group container, treat it as a child-item. if top_name not in children_for: desired_leading = top_indent + INDENT_SPACES # Ensure a blank line before if len(out) > 0 and out[-1].strip() != "": prev_non_empty = None for k in range(len(out) - 1, -1, -1): if out[k].strip() != "": prev_non_empty = out[k].lstrip() break suppress_before = False if prev_non_empty is not None: if prev_non_empty.startswith(" tag itself (retain current leading) out.append(" " * desired_leading + stripped) stack.append((m_open.group("name"), desired_leading, True)) # Skip immediate blank lines after opening i += 1 while i < n and lines[i].strip() == "": i += 1 continue # Closing group containers if m_close and m_close.group("name") in children_for: # Align to stored indent if available cont_indent = leading for name, ind, _managed in reversed(stack): if name == m_close.group("name"): cont_indent = ind break # Remove preceding blank if any if len(out) > 0 and out[-1].strip() == "": out.pop() out.append(" " * cont_indent + stripped) # Pop matching container for j in range(len(stack) - 1, -1, -1): if stack[j][0] == m_close.group("name"): stack.pop(j) break # Ensure exactly one blank line after out.append("") i += 1 # Skip multiple blanks following while i < n and lines[i].strip() == "": i += 1 continue # Opening child items if m_open and stack and m_open.group("name") in children_for.get(stack[-1][0], set()): parent_indent = stack[-1][1] child_indent = parent_indent + INDENT_SPACES is_self_closing_child = stripped.endswith("/>") out.append(" " * child_indent + stripped) if not is_self_closing_child: stack.append((m_open.group("name"), child_indent, True)) # Skip immediate blank lines after child open i += 1 while i < n and lines[i].strip() == "": i += 1 # For self-closing children, add spacing similar to explicit close handling if is_self_closing_child: jn = i while jn < n and lines[jn].strip() == "": jn += 1 if jn < n: next_lstrip = lines[jn].lstrip() if not next_lstrip.startswith(" 0 and out[-1].strip() == "": out.pop() out.append(" " * child_indent + stripped) # Pop child for j in range(len(stack) - 1, -1, -1): if stack[j][0] == m_close.group("name"): stack.pop(j) break # Ensure spacing after child close: # - No blank before container close (next non-empty starts with //), indent by one more level inside_child = None managed_child = False for name, ind, managed in reversed(stack): if name in {"Step", "Accordion", "AccordionGroup", "AccordionItem", "Card", "Tab"}: inside_child = ind managed_child = managed break if inside_child is not None and stripped != "": if managed_child: if stripped.startswith("#"): # Headings should always align to the left margin even inside managed containers out.append(stripped) else: content_indent = inside_child + INDENT_SPACES out.append(" " * content_indent + stripped) else: out.append(line) i += 1 continue # Self-closing component treated as child within group containers m_sc = SELF_CLOSING_RE.match(line) if m_sc and stack: parent_name, parent_indent, _ = stack[-1] # Only treat as child if parent is a known group container and this isn't a group close/open mismatch if parent_name in children_for and not stripped.startswith(" List[str]: # Identify simple list prefix m = LIST_RE.match(line) if m: indent = m.group("indent") marker = m.group("marker") rest = line[m.end():] # Avoid wrapping risky content if ("`" in rest) or ("|" in rest) or ("<" in rest) or ("http" in rest): return [line] fill_width = max(20, width) # safety eff_width = fill_width - len(indent) - len(marker) - 1 lines = wrap_text_with_punct_preference(rest.strip(), eff_width) result = [f"{indent}{marker} {lines[0]}"] cont_indent = " " * (len(indent) + len(marker) + 1) for seg in lines[1:]: result.append(f"{cont_indent}{seg}") return result # Plain text line s = line.strip() if not s: return [line] if is_heading(line) or is_hr(line): return [line] if TABLE_RE.search(line): return [line] if line.lstrip().startswith(("<", ">")): return [line] # Preserve original left padding for paragraphs pad = len(line) - len(line.lstrip(" ")) prefix = " " * pad wrapped_lines = wrap_text_with_punct_preference(s, width - pad) return [prefix + seg for seg in wrapped_lines] def wrap_text_with_punct_preference(text: str, width: int) -> List[str]: """Wrap text preferring breaks after punctuation when possible. Does not break long words; if a single token exceeds width, it is placed on its own line. """ if width <= 0: return [text] words = text.split() if not words: return [""] lines: List[str] = [] cur_len = 0 cur_words: List[str] = [] last_punct_idx: int | None = None def is_punct_ending(w: str) -> bool: return bool(re.search(r"[\.,;:!\?\)\]\}]$", w)) for w in words: if not cur_words: cur_words.append(w) cur_len = len(w) last_punct_idx = 1 if is_punct_ending(w) else None continue if cur_len + 1 + len(w) <= width: cur_words.append(w) cur_len += 1 + len(w) if is_punct_ending(w): last_punct_idx = len(cur_words) continue # Would overflow if last_punct_idx and last_punct_idx < len(cur_words) + 1: # Break at last punctuation within current words lines.append(" ".join(cur_words[: last_punct_idx])) # Remaining words include those after the break; current w not yet added remainder = cur_words[last_punct_idx:] cur_words = remainder or [] cur_len = len(" ".join(cur_words)) if cur_words else 0 last_punct_idx = None # Reprocess current word in next iteration by simulating step back if not cur_words: # start new line with w cur_words = [w] cur_len = len(w) last_punct_idx = 1 if is_punct_ending(w) else None else: # Try to add w now if it fits, otherwise force line if cur_len + (1 if cur_words else 0) + len(w) <= width: cur_words.append(w) cur_len += 1 + len(w) if is_punct_ending(w): last_punct_idx = len(cur_words) else: # Flush current as is lines.append(" ".join(cur_words)) cur_words = [w] cur_len = len(w) last_punct_idx = 1 if is_punct_ending(w) else None continue # No punctuation to break at: break at last whitespace (i.e., before this word) if cur_words: lines.append(" ".join(cur_words)) cur_words = [w] cur_len = len(w) last_punct_idx = 1 if is_punct_ending(w) else None if cur_words: lines.append(" ".join(cur_words)) return lines def wrap_long_lines(lines: List[str], width: int) -> List[str]: """Reflow and wrap paragraphs to the target width. - Operates outside of fenced code blocks. - Preserves headings, tables, MDX/HTML tag lines as-is and treats them as paragraph boundaries. - Handles list items with list-aware wrapping, keeping continuation indentation. - For plain paragraphs, collapses internal newlines and rewraps to the target width. """ out: List[str] = [] in_code = False in_frontmatter = False para: List[str] = [] def flush_para(): nonlocal para if not para: return # Determine minimal left padding among non-empty lines pads = [len(p) - len(p.lstrip(" ")) for p in para if p.strip()] pad = min(pads) if pads else 0 prefix = " " * pad # Build paragraph text by joining with spaces text = " ".join(p.strip() for p in para).strip() if not text: out.append("") else: wrapped_lines = wrap_text_with_punct_preference(text, max(20, width - pad)) out.extend(prefix + seg for seg in wrapped_lines) para = [] for ln in lines: # YAML frontmatter guard (only at file start) if not out and not para and ln.strip() == "---": in_frontmatter = True out.append(ln) continue if in_frontmatter: out.append(ln) if ln.strip() == "---": in_frontmatter = False continue if is_code_fence(ln): flush_para() in_code = not in_code out.append(ln) continue if in_code: out.append(ln) continue stripped = ln.strip() if stripped == "": flush_para() out.append("") continue # Boundaries and special lines stripped_l = ln.lstrip() if is_heading(ln) or is_hr(ln) or TABLE_RE.search(ln) or stripped_l.startswith(("<", ">")) or stripped_l.startswith("import ") or stripped_l.startswith("export "): flush_para() out.append(ln) continue # List items are handled individually to preserve markers and continuation indentation if LIST_RE.match(ln): flush_para() out.extend(wrap_line_to_width(ln, width)) continue # Accumulate paragraph text para.append(ln) flush_para() return out def format_content(text: str, width: int) -> str: # Repair accidentally inlined YAML frontmatter keys (if present) def fix_frontmatter(blob: str) -> str: if not blob.startswith("---\n"): return blob end = blob.find("\n---", 4) if end == -1: return blob header = blob[4:end] # If any line contains more than one key pattern, split them # Insert a newline before key-like tokens that follow spaces on the same line fixed_header = re.sub(r"(?= 4: value_norm = value_norm[1:-1] # Check if value is a boolean (true/false) - don't quote these if value_norm.lower() in ('true', 'false'): value_quoted = value_norm.lower() # Keep existing quotes if already quoted; otherwise quote with double quotes elif (len(value_norm) >= 2 and ((value_norm.startswith('"') and value_norm.endswith('"')) or (value_norm.startswith("'") and value_norm.endswith("'")))): value_quoted = value_norm else: value_quoted = '"' + value_norm.replace('"', '\\"') + '"' out_lines.append(f"{current_key}: {value_quoted}") current_key = None current_val_parts = [] for ln in lines: m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", ln) if m: # New key flush_kv() current_key = m.group(1) rest = m.group(2) current_val_parts = [rest] if rest is not None else [] else: # Continuation line for previous key if current_key is not None: current_val_parts.append(ln) else: out_lines.append(ln) flush_kv() rebuilt = "\n".join(out_lines) return "---\n" + rebuilt.strip("\n") + blob[end:] text = fix_frontmatter(text) # Split preserving line endings as \n raw_lines = text.splitlines() # 1) Normalize indentation (outside code blocks) lines: List[str] = [] in_code = False for ln in raw_lines: if is_code_fence(ln): in_code = not in_code lines.append(ln) # Don't normalize fence lines themselves continue if in_code: lines.append(ln) # Don't normalize lines inside code blocks else: lines.append(normalize_indent(ln)) # Only normalize outside code blocks # 2) Dedent common leading margin across the file (outside code and frontmatter) def dedent_common_margin(ls: List[str]) -> List[str]: out_ls: List[str] = [] in_code_f = False in_front = False # Compute minimal leading spaces among relevant lines mins: List[int] = [] for i, l in enumerate(ls): if i == 0 and l.strip() == "---": in_front = True elif in_front and l.strip() == "---": in_front = False if is_code_fence(l): in_code_f = not in_code_f continue if in_code_f or in_front: continue if l.strip() == "": continue # count spaces only lead = len(l) - len(l.lstrip(" ")) mins.append(lead) if not mins: return ls common = min(mins) if common <= 0: return ls for l in ls: # Only trim spaces, not tabs (tabs already normalized earlier) if l.startswith(" " * common): out_ls.append(l[common:]) else: out_ls.append(l) return out_ls lines = dedent_common_margin(lines) # 3) Normalize / indentation and spacing lines = normalize_steps_indentation(lines) # 4) Expand single-line advisory tags (///) to multi-line with indented content def expand_single_line_advisories(ls: List[str]) -> List[str]: out_ls: List[str] = [] in_code_f = False pat = re.compile(r"^(?P\s*)<(?PWarning|Info|Tip|Note)(?P[^>]*)>(?P.*?)\s*$") for ln in ls: if is_code_fence(ln): in_code_f = not in_code_f out_ls.append(ln) continue if in_code_f: out_ls.append(ln) continue m = pat.match(ln) if m: indent = m.group("indent") name = m.group("name") attrs = m.group("attrs") or "" inner = m.group("inner").strip() out_ls.append(f"{indent}<{name}{attrs}>") out_ls.append(f"{indent}{' ' * INDENT_SPACES}{inner}") out_ls.append(f"{indent}") else: out_ls.append(ln) return out_ls lines = expand_single_line_advisories(lines) # 5) Normalize indentation of content inside specific containers def normalize_inner_indentation(ls: List[str]) -> List[str]: target_names = {"Warning", "Info", "Tip", "Note", "Frame", "CodeGroup"} out_ls: List[str] = [] in_code_f = False stack: List[tuple[str, int]] = [] # (name, base_indent) for ln in ls: if is_code_fence(ln): in_code_f = not in_code_f s_cf = ln.lstrip(" ") # Indent code fence lines when inside CodeGroup if stack and stack[-1][0] == "CodeGroup": base = stack[-1][1] desired = base + INDENT_SPACES out_ls.append(" " * desired + s_cf) else: out_ls.append(ln) continue if in_code_f: # While inside a fenced code block: never modify content, preserve exactly as-is out_ls.append(ln) continue s = ln.lstrip(" ") leading = len(ln) - len(s) m_open = OPEN_TAG_RE.match(ln) m_close = CLOSE_TAG_RE.match(ln) if m_open and m_open.group("name") in target_names: out_ls.append(" " * leading + s) stack.append((m_open.group("name"), leading)) continue if m_close and stack and m_close.group("name") == stack[-1][0]: name, base = stack.pop() out_ls.append(" " * base + s) continue if stack: # Inside one of the target containers: enforce indent for content lines name, base = stack[-1] if s.strip() == "": out_ls.append("") else: desired = base + INDENT_SPACES out_ls.append(" " * desired + s) else: out_ls.append(ln) return out_ls lines = normalize_inner_indentation(lines) # 6) Enforce blank lines around container-level blocks lines = ensure_blank_lines_around_blocks(lines) # 6.5) Normalize MDX import/export lines and fix merged imports def normalize_mdx_imports(ls: List[str]) -> List[str]: out_ls: List[str] = [] in_code_f = False imp_pat = re.compile(r"\bimport\s+[^\n\r;]*?\s+from\s+['\"][^'\"]+['\"]") for ln in ls: if is_code_fence(ln): in_code_f = not in_code_f out_ls.append(ln) continue if in_code_f: out_ls.append(ln) continue s = ln.lstrip() lead = ln[: len(ln) - len(s)] if s.startswith("import "): parts = imp_pat.findall(s) if parts and len(parts) > 1: for p in parts: out_ls.append(lead + p) continue # Repair lines missing 'import' due to previous wrapping, e.g., 'Name from "..."' if re.match(r"^[A-Za-z_][\w]*\s+from\s+['\"]", s): out_ls.append(lead + "import " + s) continue out_ls.append(ln) return out_ls lines = normalize_mdx_imports(lines) # 6.7) Remove any blank line immediately after group container opens def remove_blank_after_group_opens(ls: List[str]) -> List[str]: out_ls: List[str] = [] in_code_f = False opens = (" List[str]: out_ls: List[str] = [] in_code_f = False # Pattern to match complete img tag (handles multi-line by joining) # Match: or img_start_pattern = re.compile(r" dict[str, str]: """Extract all attributes from an img tag, handling both quoted and JSX-style attributes.""" attrs: dict[str, str] = {} # Pattern for quoted attributes: attr="value" or attr='value' quoted_pattern = re.compile(r'(\w+)\s*=\s*(["\'])([^"\']*)\2', re.IGNORECASE) for match in quoted_pattern.finditer(content): attr_name = match.group(1).lower() quote_char = match.group(2) attr_value = match.group(3) attrs[attr_name] = f'{quote_char}{attr_value}{quote_char}' # Pattern for JSX-style attributes: attr={...} # We need to handle nested braces, so we'll find the opening { and match the closing } jsx_pattern = re.compile(r'(\w+)\s*=\s*(\{)', re.IGNORECASE) pos = 0 while True: match = jsx_pattern.search(content, pos) if not match: break attr_name = match.group(1).lower() brace_start = match.start(2) # Find matching closing brace brace_count = 0 brace_end = brace_start for i in range(brace_start, len(content)): if content[i] == '{': brace_count += 1 elif content[i] == '}': brace_count -= 1 if brace_count == 0: brace_end = i break if brace_count == 0: attr_value = content[brace_start:brace_end + 1] attrs[attr_name] = attr_value pos = brace_end + 1 return attrs i = 0 n = len(ls) while i < n: ln = ls[i] if is_code_fence(ln): in_code_f = not in_code_f out_ls.append(ln) i += 1 continue if in_code_f: out_ls.append(ln) i += 1 continue stripped = ln.lstrip() leading = len(ln) - len(stripped) # Check if line starts with or >) stripped_rstrip = stripped.rstrip() if stripped_rstrip.endswith("/>") or stripped_rstrip.endswith(">"): tag_closed = True # Collect continuation lines until tag is closed # Stop immediately if we hit a blank line, code fence, or other tag while j < n and not tag_closed: if is_code_fence(ls[j]): break # Stop if we hit a blank line (img tag shouldn't span blank lines) if ls[j].strip() == "": break next_line = ls[j].lstrip() # Stop if next line starts a new tag or component (but allow if it's another img tag continuation) if next_line.startswith("<"): # If it's not an img tag, stop collecting - this line is NOT part of the img tag if not img_start_pattern.match(next_line): break # This line is part of the img tag, add it img_parts.append(next_line) next_rstrip = next_line.rstrip() if next_rstrip.endswith("/>") or next_rstrip.endswith(">"): tag_closed = True break j += 1 # Join all parts into a single string for processing img_content = " ".join(img_parts) # Extract all attributes using the helper function attrs = extract_attributes(img_content) # Extract values for required attributes className_val = None class_val = None # fallback for "class" attribute src_val = None alt_val = None for attr_name, attr_value in attrs.items(): if attr_name == "classname": # Extract value, handling both quoted and JSX-style if attr_value.startswith("{") and attr_value.endswith("}"): className_val = attr_value[1:-1].strip().strip('"').strip("'") else: className_val = attr_value.strip('"').strip("'") elif attr_name == "class": if attr_value.startswith("{") and attr_value.endswith("}"): class_val = attr_value[1:-1].strip().strip('"').strip("'") else: class_val = attr_value.strip('"').strip("'") elif attr_name == "src": if attr_value.startswith("{") and attr_value.endswith("}"): src_val = attr_value[1:-1].strip().strip('"').strip("'") else: src_val = attr_value.strip('"').strip("'") elif attr_name == "alt": if attr_value.startswith("{") and attr_value.endswith("}"): alt_val = attr_value[1:-1].strip().strip('"').strip("'") else: alt_val = attr_value.strip('"').strip("'") # Use className if available, otherwise fall back to class if not className_val and class_val: className_val = class_val # Update attrs dict to use className instead of class if "classname" not in attrs: attrs["classname"] = attrs.pop("class", f'"{class_val}"') # Build normalized img tag if we have all required attributes if className_val and src_val and alt_val: # Determine closing style is_self_closing = "/>" in img_content closing = "/>" if is_self_closing else ">" # Build attribute list: className first, then src, then alt, then others attr_parts = [] # Add className first if "classname" in attrs: attr_parts.append(f'className={attrs["classname"]}') # Add src second if "src" in attrs: attr_parts.append(f'src={attrs["src"]}') # Add alt third if "alt" in attrs: attr_parts.append(f'alt={attrs["alt"]}') # Add all other attributes (preserve original order) for attr_name, attr_value in attrs.items(): if attr_name not in {"classname", "class", "src", "alt"}: attr_parts.append(f'{attr_name}={attr_value}') normalized = f'", "/>").replace(" >", ">") out_ls.append(" " * leading + normalized) # IMPORTANT: j points to the first line AFTER the img tag # This could be a blank line, closing tag like , or another element # We set i = j so the next iteration processes that line normally i = j else: # If any required attribute is missing, keep original lines # Output all lines from i to j-1 (the img tag lines) for k in range(i, j): out_ls.append(ls[k]) # Move to line j (first line after img tag) i = j else: # Not an img tag line, preserve as-is out_ls.append(ln) i += 1 return out_ls lines = normalize_img_tags(lines) # 7) Wrap long lines (outside code blocks) lines = wrap_long_lines(lines, width) # 8) Remove trailing whitespace on every line def strip_trailing_ws(ls: List[str]) -> List[str]: return [l.rstrip() for l in ls] lines = strip_trailing_ws(lines) # Always ensure exactly one trailing newline at EOF return ("\n".join(lines)).rstrip("\n") + "\n" def process_file(path: str, write: bool, width: int) -> bool: try: with open(path, "r", encoding="utf-8") as f: original = f.read() except UnicodeDecodeError: # Skip non-UTF8 files return False formatted = format_content(original, width) if formatted != original: if write: with open(path, "w", encoding="utf-8", newline="\n") as f: f.write(formatted) return True return False def find_numbered_list_spacing_warnings(path: str, text: str) -> List[tuple[int, str]]: warnings: List[tuple[int, str]] = [] in_code = False for idx, ln in enumerate(text.splitlines(), start=1): if is_code_fence(ln): in_code = not in_code continue if in_code: continue # Ignore MDX/HTML lines and tables if ln.lstrip().startswith(("<", ">")): continue if TABLE_RE.search(ln): continue m = BAD_NUM_LIST_RE.match(ln) if m: # m.group(1) is the first non-space char after the marker warnings.append((idx, ln.rstrip())) return warnings def find_numbered_list_occurrences(path: str, text: str) -> List[tuple[int, str]]: found: List[tuple[int, str]] = [] in_code = False for idx, ln in enumerate(text.splitlines(), start=1): if is_code_fence(ln): in_code = not in_code continue if in_code: continue # Ignore MDX/HTML lines and tables if ln.lstrip().startswith(("<", ">")): continue if TABLE_RE.search(ln): continue if ANY_NUM_LIST_RE.match(ln): found.append((idx, ln.rstrip())) return found def run_mintlify_broken_links(root: str) -> int: """Attempt to run `mintlify broken-links` and print its output. Returns the subprocess return code if executed, otherwise 0. """ mintlify = shutil.which("mintlify") if not mintlify: print("mintlify CLI not found. Skipping 'mintlify broken-links'.") print("Install with: npm i -g mintlify") return 0 try: print("Running: mintlify broken-links") proc = subprocess.run( [mintlify, "broken-links"], cwd=root, capture_output=True, text=True, ) # Forward stdout/stderr to user if proc.stdout: print(proc.stdout.rstrip()) if proc.stderr: print(proc.stderr.rstrip(), file=sys.stderr) return proc.returncode except Exception as e: print(f"Failed to run mintlify broken-links: {e}") return 1 LINK_MD_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)") IMG_MD_RE = re.compile(r"!\[[^\]]*\]\(([^)]+)\)") IMG_MDX_RE = re.compile(r"<(?:img|Image)[^>]*\s+src=\"([^\"]+)\"[\s\S]*?/?>") def fallback_broken_asset_check(root: str, path: str, text: str) -> List[tuple[int, str, str]]: """Simple local-asset existence check for links/images when mintlify is unavailable. Returns list of (line_number, kind, target) for missing local targets. Only checks paths that look local (start with '/' or relative paths without schema). """ results: List[tuple[int, str, str]] = [] in_code = False for idx, ln in enumerate(text.splitlines(), start=1): if is_code_fence(ln): in_code = not in_code continue if in_code: continue # Collect candidates from Markdown links/images targets: List[tuple[str, str]] = [] # (kind, target) for m in IMG_MD_RE.finditer(ln): targets.append(("image", m.group(1))) for m in LINK_MD_RE.finditer(ln): targets.append(("link", m.group(1))) for m in IMG_MDX_RE.finditer(ln): targets.append(("image", m.group(1))) for kind, target in targets: t = target.strip() # Skip anchors and mailto and http(s) if t.startswith("#") or "://" in t or t.startswith("mailto:"): continue # Normalize path if t.startswith("/"): candidate = os.path.join(root, t.lstrip("/")) else: candidate = os.path.join(os.path.dirname(os.path.join(root, path)), t) if not os.path.exists(candidate): results.append((idx, kind, t)) return results def main() -> int: parser = argparse.ArgumentParser(description="Format Markdown/MDX docs") parser.add_argument("--check", action="store_true", help="Only report files that would change") parser.add_argument("--write", action="store_true", help="Write changes to files") parser.add_argument("--width", type=int, default=120, help="Wrap width for text lines") parser.add_argument( "--paths", nargs="*", default=[], help="Optional list of files/directories to format (defaults to entire repo).", ) # Link checks always run; flag retained for compatibility but ignored parser.add_argument("--check-links", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args() if not args.check and not args.write: print("Specify --check or --write", file=sys.stderr) return 2 root = os.getcwd() if args.paths: files: List[str] = [] for p in args.paths: abspath = os.path.abspath(p) if os.path.isdir(abspath): files.extend(iter_doc_files(abspath)) else: files.append(abspath) else: files = iter_doc_files(root) changed: List[str] = [] numbered_list_warnings: List[tuple[str, int, str]] = [] numbered_list_found: List[tuple[str, int, str]] = [] frontmatter_icon_warnings: List[str] = [] for p in files: try: with open(p, "r", encoding="utf-8") as f: original = f.read() except UnicodeDecodeError: original = "" if original: # Frontmatter icon check (skip for admins/connectors/* pages) if original.startswith("---\n"): end = original.find("\n---", 4) if end != -1: header = original[4:end] has_icon = any(re.match(r"^icon:\s*", ln.strip()) for ln in header.splitlines()) rel = os.path.relpath(p, root).replace("\\", "/") is_exempt = ( rel.startswith("admins/connectors/") or rel.startswith("snippets/") or rel.startswith("developers/api_reference/") or rel.startswith("deployment/cloud/aws") ) if not has_icon and not is_exempt: frontmatter_icon_warnings.append(p) for ln_no, ln_text in find_numbered_list_spacing_warnings(p, original): numbered_list_warnings.append((p, ln_no, ln_text)) for ln_no, ln_text in find_numbered_list_occurrences(p, original): numbered_list_found.append((p, ln_no, ln_text)) if process_file(p, write=args.write, width=args.width): changed.append(p) # Emit numbered list warnings (informational, non-fatal) if numbered_list_found: print("Numbered lists detected (consider using / where appropriate):") for p, ln_no, ln_text in numbered_list_found: print(f" {p}:{ln_no}: '{ln_text}'") # Emit numbered list spacing warnings (missing space after numeric marker) if numbered_list_warnings: print("Numbered list spacing warnings (missing space after numeric marker):") for p, ln_no, ln_text in numbered_list_warnings: print(f" {p}:{ln_no}: '{ln_text}'") if frontmatter_icon_warnings: print("Frontmatter warnings: missing 'icon' field (all pages should have icons):") for p in frontmatter_icon_warnings: print(f" {p}") # Always run link check via mintlify if available (no fallback) rc = run_mintlify_broken_links(root) if rc != 0: print(f"mintlify broken-links exited with code {rc}") if args.check: if changed: print(f"{len(changed)} file(s) would be reformatted:") for p in changed: print(p) return 1 else: print("All files are correctly formatted.") return 0 else: print(f"Reformatted {len(changed)} file(s).") return 0 if __name__ == "__main__": raise SystemExit(main())