From a4be5bed0d3196fe1759078854f295c04966fefa Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:31:39 -0700 Subject: [PATCH] fix: parse class suffixes, labeled dotted/thick links, and frontmatter --- scripts/verify-mermaid-import.py | 48 +++++++++++++++++++ .../references/import-mermaid.md | 2 +- .../diagram-design/scripts/mermaid_extract.py | 48 +++++++++++++++++-- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/scripts/verify-mermaid-import.py b/scripts/verify-mermaid-import.py index 0ebf132..54ba018 100644 --- a/scripts/verify-mermaid-import.py +++ b/scripts/verify-mermaid-import.py @@ -181,9 +181,56 @@ A --> B; B --> C ("B", "C"), ]: fail("semicolon-separated flowchart statements were not split") + + labeled_file = tmp / "labeled-links.mmd" + labeled_file.write_text( + """flowchart LR +A[Start]:::warning --> B +B -. retry .-> C +C == critical ==> D +D:::danger --- E +""", + encoding="utf-8", + ) + labeled = json.loads(run_extract([str(labeled_file), "--json"]))["diagrams"][0] + ids = [node["id"] for node in labeled["nodes"]] + if ids != ["A", "B", "C", "D", "E"]: + fail(f"`:::class` suffixes leaked into node ids: {ids}") + if labeled["nodes"][0]["label"] != "Start": + fail("shaped node with a class suffix lost its label") + dotted, thick = labeled["edges"][1], labeled["edges"][2] + if dotted["label"] != "retry" or dotted["style"] != "dashed": + fail("labeled dotted link did not retain its label and dashed style") + if thick["label"] != "critical" or thick["style"] != "thick": + fail("labeled thick link did not retain its label and thick style") ok("documented shape families and edge forms normalize") +def check_frontmatter(tmp: Path) -> None: + front_file = tmp / "frontmatter.mmd" + front_file.write_text( + """--- +title: Checkout +config: + theme: forest +--- +flowchart LR +A --> B +""", + encoding="utf-8", + ) + digest = run_extract([str(front_file)]) + if "direction: LR" not in digest or "[0] flowchart (2n/1e)" not in digest: + fail("leading Mermaid frontmatter was not skipped before grammar detection") + if "Checkout" in digest or "forest" in digest: + fail("frontmatter config leaked into the digest") + + unterminated = tmp / "unterminated-frontmatter.mmd" + unterminated.write_text("---\ntitle: Broken\nflowchart LR\nA --> B\n", encoding="utf-8") + expect_error([str(unterminated)], "not a Mermaid file") + ok("frontmatter is skipped, and an unterminated block still fails specifically") + + def check_markdown_and_grammars(tmp: Path) -> None: header = run_extract([str(README_FIXTURE)]) if "2 diagram(s)" not in header or "[1] sequenceDiagram" not in header: @@ -480,6 +527,7 @@ def main() -> int: check_files() check_flowchart() check_shape_and_edge_vocabulary(tmp) + check_frontmatter(tmp) check_markdown_and_grammars(tmp) check_adversarial(tmp) check_errors_and_limits(tmp) diff --git a/skills/diagram-design/references/import-mermaid.md b/skills/diagram-design/references/import-mermaid.md index 17d2d64..f525ae8 100644 --- a/skills/diagram-design/references/import-mermaid.md +++ b/skills/diagram-design/references/import-mermaid.md @@ -63,7 +63,7 @@ Load the selected `type-*.md`. Override the grammar only when the content disagr - Start from a blank `viewBox` selected by the size preset. Mermaid positions do not exist in the source, and a renderer's positions must not be recreated. - Use semantic treatments from the chosen type. A Mermaid cylinder becomes Store/State; a rhombus stays a decision only in a flowchart; subgraphs become zones or collapsible groups. -- Ignore init themes, `style`, `classDef`, `class`, and `linkStyle`. One accent plus the ink ramp replaces the source theme. +- Ignore init themes, `style`, `classDef`, `class`, inline `:::class` attachments, and `linkStyle`. One accent plus the ink ramp replaces the source theme. A leading `---` frontmatter block is title/config, so it is skipped with the same reasoning. - Reroute all connections with the SKILL.md ยง6 connector rules. Mermaid edge length markers are ranking hints, not content. - Do not add a component merely to fill space. Imports remain bounded by source meaning. diff --git a/skills/diagram-design/scripts/mermaid_extract.py b/skills/diagram-design/scripts/mermaid_extract.py index 2b9caab..2f3a133 100644 --- a/skills/diagram-design/scripts/mermaid_extract.py +++ b/skills/diagram-design/scripts/mermaid_extract.py @@ -253,12 +253,37 @@ def load_blocks(path: Path) -> list[SourceBlock]: return blocks +FRONTMATTER_MAX_LINES = 40 + + +def _frontmatter_end(lines: list[str]) -> int: + """Return the last line index of a leading `---` frontmatter block, or -1.""" + first = next( + (index for index, line in enumerate(lines) if line.strip()), + None, + ) + if first is None or lines[first].strip() != "---": + return -1 + limit = min(len(lines), first + FRONTMATTER_MAX_LINES + 1) + for index in range(first + 1, limit): + if lines[index].strip() == "---": + return index + return -1 + + def _prepared_lines(block: SourceBlock) -> list[tuple[int, str]]: prepared: list[tuple[int, str]] = [] in_directive = False - for offset, raw in enumerate(block.text.splitlines()): + lines = block.text.splitlines() + frontmatter_end = _frontmatter_end(lines) + for offset, raw in enumerate(lines): line_number = block.source_line + offset stripped = raw.strip() + if offset <= frontmatter_end: + # Mermaid's `--- title: ... ---` frontmatter is source config; the + # redraw discards it the same way it discards `%%{init}%%`. + prepared.append((line_number, "")) + continue if in_directive: if "}%%" in stripped: in_directive = False @@ -373,8 +398,20 @@ def classify_shape(expression: str) -> str: return "rect" +def _strip_class_suffix(text: str) -> str: + """Drop Mermaid's `:::class` attachment; source styling is discarded.""" + mask = _top_level_mask(text) + index = mask.find(":::") + if index == -1: + return text + end = index + 3 + while end < len(text) and (text[end].isalnum() or text[end] in "_-"): + end += 1 + return (text[:index] + text[end:]).strip() + + def _parse_node_expression(expression: str) -> tuple[str, str, str] | None: - text = expression.strip().rstrip(";").strip() + text = _strip_class_suffix(expression.strip().rstrip(";").strip()) if not text: return None match = re.match(r"^([\w.:-]+)", text, re.UNICODE) @@ -415,7 +452,12 @@ def _edge_operators(text: str) -> list[_Operator]: operators: list[_Operator] = [] occupied: list[tuple[int, int]] = [] - text_edge = re.compile(r"--\s+(.+?)\s+(-{2,}>|--[xo])") + # Labeled links carry the label between the opening and closing operator: + # `A-- text -->B`, `A-. retry .-> B`, `A== critical ==> B`, and the + # undirected forms of each. + text_edge = re.compile( + r"(?:--|-\.|==)\s+(.+?)\s+(\.-+[>xo]|\.-+|-{2,}>|--[xo]|=+>|={2,}|-{3,})" + ) for match in text_edge.finditer(mask): token = match.group(2) style, arrowhead, bidirectional, undirected = _operator_style(token)