fix: parse class suffixes, labeled dotted/thick links, and frontmatter
This commit is contained in:
@@ -181,9 +181,56 @@ A --> B; B --> C
|
|||||||
("B", "C"),
|
("B", "C"),
|
||||||
]:
|
]:
|
||||||
fail("semicolon-separated flowchart statements were not split")
|
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")
|
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:
|
def check_markdown_and_grammars(tmp: Path) -> None:
|
||||||
header = run_extract([str(README_FIXTURE)])
|
header = run_extract([str(README_FIXTURE)])
|
||||||
if "2 diagram(s)" not in header or "[1] sequenceDiagram" not in header:
|
if "2 diagram(s)" not in header or "[1] sequenceDiagram" not in header:
|
||||||
@@ -480,6 +527,7 @@ def main() -> int:
|
|||||||
check_files()
|
check_files()
|
||||||
check_flowchart()
|
check_flowchart()
|
||||||
check_shape_and_edge_vocabulary(tmp)
|
check_shape_and_edge_vocabulary(tmp)
|
||||||
|
check_frontmatter(tmp)
|
||||||
check_markdown_and_grammars(tmp)
|
check_markdown_and_grammars(tmp)
|
||||||
check_adversarial(tmp)
|
check_adversarial(tmp)
|
||||||
check_errors_and_limits(tmp)
|
check_errors_and_limits(tmp)
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- Do not add a component merely to fill space. Imports remain bounded by source meaning.
|
||||||
|
|
||||||
|
|||||||
@@ -253,12 +253,37 @@ def load_blocks(path: Path) -> list[SourceBlock]:
|
|||||||
return blocks
|
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]]:
|
def _prepared_lines(block: SourceBlock) -> list[tuple[int, str]]:
|
||||||
prepared: list[tuple[int, str]] = []
|
prepared: list[tuple[int, str]] = []
|
||||||
in_directive = False
|
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
|
line_number = block.source_line + offset
|
||||||
stripped = raw.strip()
|
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_directive:
|
||||||
if "}%%" in stripped:
|
if "}%%" in stripped:
|
||||||
in_directive = False
|
in_directive = False
|
||||||
@@ -373,8 +398,20 @@ def classify_shape(expression: str) -> str:
|
|||||||
return "rect"
|
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:
|
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:
|
if not text:
|
||||||
return None
|
return None
|
||||||
match = re.match(r"^([\w.:-]+)", text, re.UNICODE)
|
match = re.match(r"^([\w.:-]+)", text, re.UNICODE)
|
||||||
@@ -415,7 +452,12 @@ def _edge_operators(text: str) -> list[_Operator]:
|
|||||||
operators: list[_Operator] = []
|
operators: list[_Operator] = []
|
||||||
occupied: list[tuple[int, int]] = []
|
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):
|
for match in text_edge.finditer(mask):
|
||||||
token = match.group(2)
|
token = match.group(2)
|
||||||
style, arrowhead, bidirectional, undirected = _operator_style(token)
|
style, arrowhead, bidirectional, undirected = _operator_style(token)
|
||||||
|
|||||||
Reference in New Issue
Block a user