From b6b666d705d6e5fe9a1640c8ddb7d4103f783443 Mon Sep 17 00:00:00 2001 From: Justin Greywolf Date: Tue, 21 Oct 2025 14:31:14 -0700 Subject: [PATCH] feat: support multiple stereotypes in class diagrams --- .../rendering/classDiagram-elk-v3.spec.js | 13 +++ .../classDiagram-handDrawn-v3.spec.js | 13 +++ .../rendering/classDiagram-v2.spec.js | 10 ++ .../rendering/classDiagram-v3.spec.js | 11 +++ .../rendering/classDiagram.spec.js | 14 +++ demos/class-multiple-stereotypes.html | 93 +++++++++++++++++++ demos/classchart.html | 16 +--- .../diagrams/class/parser/classDiagram.jison | 28 ++++++ .../mermaid/src/diagrams/class/shapeUtil.ts | 4 +- .../mermaid/src/docs/syntax/classDiagram.md | 31 +++---- 10 files changed, 198 insertions(+), 35 deletions(-) create mode 100644 demos/class-multiple-stereotypes.html diff --git a/cypress/integration/rendering/classDiagram-elk-v3.spec.js b/cypress/integration/rendering/classDiagram-elk-v3.spec.js index ee6ca0b2b..292d4b2fe 100644 --- a/cypress/integration/rendering/classDiagram-elk-v3.spec.js +++ b/cypress/integration/rendering/classDiagram-elk-v3.spec.js @@ -661,6 +661,19 @@ class Class10 { logLevel: 1, htmlLabels: true, layout: 'elk' } ); }); + + it('ELK: should render a class with a text label, members and multiple annotations', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] { + <<interface>> <<injected>> + +member1 + } + C1 --> C2`, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render multiple classes with same text labels', () => { imgSnapshotTest( `classDiagram diff --git a/cypress/integration/rendering/classDiagram-handDrawn-v3.spec.js b/cypress/integration/rendering/classDiagram-handDrawn-v3.spec.js index 32a82c089..a6890379f 100644 --- a/cypress/integration/rendering/classDiagram-handDrawn-v3.spec.js +++ b/cypress/integration/rendering/classDiagram-handDrawn-v3.spec.js @@ -661,6 +661,19 @@ class Class10 { logLevel: 1, htmlLabels: true, look: 'handDrawn' } ); }); + + it('HD: should render a class with a text label, membersand multiple annotations', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] { + <<interface>> <<injected>> + +member1 + } + C1 --> C2`, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render multiple classes with same text labels', () => { imgSnapshotTest( `classDiagram diff --git a/cypress/integration/rendering/classDiagram-v2.spec.js b/cypress/integration/rendering/classDiagram-v2.spec.js index 0c5dbc04b..d905ae4c7 100644 --- a/cypress/integration/rendering/classDiagram-v2.spec.js +++ b/cypress/integration/rendering/classDiagram-v2.spec.js @@ -510,6 +510,16 @@ class Class10 C1 --> C2` ); }); + it('should render a class with a text label, members and multiple annotations', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] { + <<interface>> <<injected>> + +member1 + } + C1 --> C2` + ); + }); it('should render multiple classes with same text labels', () => { imgSnapshotTest( `classDiagram diff --git a/cypress/integration/rendering/classDiagram-v3.spec.js b/cypress/integration/rendering/classDiagram-v3.spec.js index 626d6fcea..68412fe81 100644 --- a/cypress/integration/rendering/classDiagram-v3.spec.js +++ b/cypress/integration/rendering/classDiagram-v3.spec.js @@ -657,6 +657,17 @@ class Class10 C1 --> C2` ); }); + it('should render a class with a text label, members and multiple annotations', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] { + <<interface>> <<injected>> + +member1 + } + C1 --> C2` + ); + }); + it('should render multiple classes with same text labels', () => { imgSnapshotTest( `classDiagram diff --git a/cypress/integration/rendering/classDiagram.spec.js b/cypress/integration/rendering/classDiagram.spec.js index 6cea402f8..a629ab982 100644 --- a/cypress/integration/rendering/classDiagram.spec.js +++ b/cypress/integration/rendering/classDiagram.spec.js @@ -452,6 +452,20 @@ describe('Class diagram', () => { <<Interface>> \`This\nTitle\nHas\nMany\nNewlines\` `); }); + it('should render with newlines in title and multiple annotations', () => { + imgSnapshotTest(` + classDiagram + class \`This\nTitle\nHas\nMany\nNewlines\` { + +String Also + -String Many + #int Members + +And() + -Many() + #Methods() + } + <<Interface>> <<Service>> \`This\nTitle\nHas\nMany\nNewlines\` + `); + }); it('should handle newline title in namespace', () => { imgSnapshotTest(` diff --git a/demos/class-multiple-stereotypes.html b/demos/class-multiple-stereotypes.html new file mode 100644 index 000000000..86eb91ac4 --- /dev/null +++ b/demos/class-multiple-stereotypes.html @@ -0,0 +1,93 @@ + + + + + + Class Diagram - Multiple Stereotypes Test + + + + + +

Multiple Stereotypes Test

+ +

Test 1: Inline with class definition (single)

+
+    classDiagram
+      class Shape <<interface>>
+    
+ +

Test 2: Inline with class definition (multiple)

+
+    classDiagram
+      class Shape <<interface>> <<injected>>
+    
+ +

Test 3: Separate line (single)

+
+    classDiagram
+      class Shape
+      <<interface>> Shape
+    
+ +

Test 4: Separate line (multiple)

+
+    classDiagram
+      class Shape
+      <<interface>> <<injected>> Shape
+    
+ +

Test 5: Inside class body (single)

+
+    classDiagram
+    class Shape{
+        <<interface>>
+        noOfVertices
+        draw()
+    }
+    
+ +

Test 6: Inside class body (multiple on same line)

+
+    classDiagram
+    class Shape{
+        <<interface>> <<injected>>
+        noOfVertices
+        draw()
+    }
+    
+ +

Test 7: Combined example

+
+    classDiagram
+    class Shape{
+        <<interface>> <<injected>>
+        noOfVertices
+        draw()
+    }
+    class Color{
+        <<enumeration>>
+        RED
+        BLUE
+        GREEN
+        WHITE
+        BLACK
+    }
+    Shape <|-- Color
+    
+ +
+ + + diff --git a/demos/classchart.html b/demos/classchart.html index 10d8e6b70..6b808a7ff 100644 --- a/demos/classchart.html +++ b/demos/classchart.html @@ -38,7 +38,7 @@ +quack() } class Fish{ - -Listint sizeInFeet + -int sizeInFeet -canEat() } class Zebra{ @@ -143,21 +143,7 @@ Pineapple : -int leafCount() Pineapple : -int spikeCount() -
-
-    classDiagram
-      class Person {
-        +ID : Guid
-        +FirstName : string
-        +LastName : string
-        -privateProperty : string
-        #ProtectedProperty : string
-        ~InternalProperty : string
-        ~AnotherInternalProperty : List~List~string~~
-      }
-      class People List~List~Person~~
-    

     classDiagram
diff --git a/packages/mermaid/src/diagrams/class/parser/classDiagram.jison b/packages/mermaid/src/diagrams/class/parser/classDiagram.jison
index 9a1f991a7..15daa3c85 100644
--- a/packages/mermaid/src/diagrams/class/parser/classDiagram.jison
+++ b/packages/mermaid/src/diagrams/class/parser/classDiagram.jison
@@ -18,6 +18,7 @@
 %x acc_descr_multiline
 %x class
 %x class-body
+%x class-body-annotation
 %x namespace
 %x namespace-body
 %%
@@ -82,9 +83,16 @@ Function arguments are optional: 'call ()' simply executes 'callb
 <>             return "EOF_IN_STRUCT";
 "[*]"               { return 'EDGE_STATE';}
 [{]                 return "OPEN_IN_STRUCT";
+"<<"                { this.begin("class-body-annotation"); return 'ANNOTATION_START';}
 [\n]                /* nothing */
+[ \t]+              /* skip whitespace in class body */
 [^{}\n]*            { return "MEMBER";}
 
+">>"     { this.popState(); return 'ANNOTATION_END';}
+[0-9]+   return 'NUM';
+\w+      return 'ALPHA';
+[\s]+    /* ignore whitespace */
+
 <*>"cssClass"                   return 'CSSCLASS';
 <*>"callback"                   return 'CALLBACK';
 <*>"link"                       return 'LINK';
@@ -294,12 +302,26 @@ classStatement
     | classIdentifier STYLE_SEPARATOR alphaNumToken      {yy.setCssClass($1, $3);}
     | classIdentifier STRUCT_START members STRUCT_STOP   {yy.addMembers($1,$3);}
     | classIdentifier STRUCT_START STRUCT_STOP           {}
+    | classIdentifier STRUCT_START NEWLINE members STRUCT_STOP {yy.addMembers($1,$4);}
+    | classIdentifier STRUCT_START annotationList members STRUCT_STOP {for(const annotation of $3) { yy.addAnnotation($1, annotation); } yy.addMembers($1,$4);}
+    | classIdentifier STRUCT_START annotationList STRUCT_STOP {for(const annotation of $3) { yy.addAnnotation($1, annotation); }}
+    | classIdentifier STRUCT_START NEWLINE annotationList members STRUCT_STOP {for(const annotation of $4) { yy.addAnnotation($1, annotation); } yy.addMembers($1,$5);}
+    | classIdentifier STRUCT_START NEWLINE annotationList STRUCT_STOP {for(const annotation of $4) { yy.addAnnotation($1, annotation); }}
     | classIdentifier STYLE_SEPARATOR alphaNumToken STRUCT_START members STRUCT_STOP {yy.setCssClass($1, $3);yy.addMembers($1,$5);}
+    | classIdentifier STYLE_SEPARATOR alphaNumToken STRUCT_START NEWLINE members STRUCT_STOP {yy.setCssClass($1, $3);yy.addMembers($1,$6);}
+    | classIdentifier STYLE_SEPARATOR alphaNumToken STRUCT_START annotationList members STRUCT_STOP {yy.setCssClass($1, $3); for(const annotation of $5) { yy.addAnnotation($1, annotation); } yy.addMembers($1,$6);}
+    | classIdentifier STYLE_SEPARATOR alphaNumToken STRUCT_START annotationList STRUCT_STOP {yy.setCssClass($1, $3); for(const annotation of $5) { yy.addAnnotation($1, annotation); }}
+    | classIdentifier STYLE_SEPARATOR alphaNumToken STRUCT_START NEWLINE annotationList members STRUCT_STOP {yy.setCssClass($1, $3); for(const annotation of $6) { yy.addAnnotation($1, annotation); } yy.addMembers($1,$7);}
+    | classIdentifier STYLE_SEPARATOR alphaNumToken STRUCT_START NEWLINE annotationList STRUCT_STOP {yy.setCssClass($1, $3); for(const annotation of $6) { yy.addAnnotation($1, annotation); }}
     ;
 
 classIdentifier
     : CLASS className                                    {$$=$2; yy.addClass($2);}
+    | CLASS className ANNOTATION_START alphaNumToken ANNOTATION_END {$$=$2; yy.addClass($2); yy.addAnnotation($2,$4);}
+    | CLASS className annotationList                     {$$=$2; yy.addClass($2); for(const annotation of $3) { yy.addAnnotation($2, annotation); }}
     | CLASS className classLabel                         {$$=$2; yy.addClass($2);yy.setClassLabel($2, $3);}
+    | CLASS className classLabel ANNOTATION_START alphaNumToken ANNOTATION_END {$$=$2; yy.addClass($2);yy.setClassLabel($2, $3); yy.addAnnotation($2,$5);}
+    | CLASS className classLabel annotationList          {$$=$2; yy.addClass($2);yy.setClassLabel($2, $3); for(const annotation of $4) { yy.addAnnotation($2, annotation); }}
     ;
 
 
@@ -311,6 +333,12 @@ emptyBody
 
 annotationStatement
     : ANNOTATION_START alphaNumToken ANNOTATION_END className  { yy.addAnnotation($4,$2); }
+    | annotationList className  { for(const annotation of $1) { yy.addAnnotation($2, annotation); } }
+    ;
+
+annotationList
+    : ANNOTATION_START alphaNumToken ANNOTATION_END { $$ = [$2]; }
+    | annotationList ANNOTATION_START alphaNumToken ANNOTATION_END { $1.push($3); $$ = $1; }
     ;
 
 members
diff --git a/packages/mermaid/src/diagrams/class/shapeUtil.ts b/packages/mermaid/src/diagrams/class/shapeUtil.ts
index 94c8f817a..cdf9e8a31 100644
--- a/packages/mermaid/src/diagrams/class/shapeUtil.ts
+++ b/packages/mermaid/src/diagrams/class/shapeUtil.ts
@@ -36,8 +36,8 @@ export async function textHelper(
 
   annotationGroup = shapeSvg.insert('g').attr('class', 'annotation-group text');
   if (node.annotations.length > 0) {
-    const annotation = node.annotations[0];
-    await addText(annotationGroup, { text: `«${annotation}»` } as unknown as ClassMember, 0);
+    const annotationText = node.annotations.map((a: string) => `«${a}»`).join('\n');
+    await addText(annotationGroup, { text: annotationText } as unknown as ClassMember, 0);
 
     const annotationGroupBBox = annotationGroup.node()!.getBBox();
     annotationGroupHeight = annotationGroupBBox.height;
diff --git a/packages/mermaid/src/docs/syntax/classDiagram.md b/packages/mermaid/src/docs/syntax/classDiagram.md
index ed69bd223..a98b3cd41 100644
--- a/packages/mermaid/src/docs/syntax/classDiagram.md
+++ b/packages/mermaid/src/docs/syntax/classDiagram.md
@@ -358,17 +358,19 @@ It is possible to annotate classes with markers to provide additional metadata a
 - `<>` To represent a service class
 - `<>` To represent an enum
 
-Annotations are defined within the opening `<<` and closing `>>`. There are two ways to add an annotation to a class, and either way the output will be same:
+Annotations are defined within the opening `<<` and closing `>>`. There are multiple ways to add an annotation to a class, which all result in the same output, and you can add multiple annotations by adding others on the same line:
 
-> **Tip:**  
-> In Mermaid class diagrams, annotations like `<>` can be attached in two ways:
->
 > - **Inline with the class definition** (Recommended for consistency):
 >
 >   ```mermaid-example
 >   classDiagram
 >     class Shape <>
 >   ```
+
+> ```mermaid-example
+> classDiagram
+>   class Shape <> <>
+> ```
 >
 > - **Separate line after the class definition**:
 >
@@ -378,25 +380,18 @@ Annotations are defined within the opening `<<` and closing `>>`. There are two
 >     <> Shape
 >   ```
 >
-> Both methods are fully supported and produce identical diagrams.  
-> However, it is recommended to use the **inline style** for better readability and consistent formatting across diagrams.
+>   ```mermaid-example
+>   classDiagram
+>     class Shape
+>     <> <> Shape
+>   ```
 
-- In a **_separate line_** after a class is defined:
-
-```mermaid-example
-classDiagram
-class Shape
-<> Shape
-Shape : noOfVertices
-Shape : draw()
-```
-
-- In a **_nested structure_** along with the class definition:
+> In a **_nested structure_** along with the class definition:
 
 ```mermaid-example
 classDiagram
 class Shape{
-    <>
+    <> <>
     noOfVertices
     draw()
 }