Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7d2c918
feat(gitGraph): add construction-time interaction support and refined…
mkobit May 16, 2026
0e96ab4
docs: synchronize transformed documentation files
mkobit May 16, 2026
7bf137a
docs: finalize synchronization of documentation files
mkobit May 16, 2026
2e5ad51
fix(gitgraph): maintain pixel parity for non-interactive elements
mkobit May 17, 2026
c764064
[autofix.ci] apply automated fixes
autofix-ci[bot] May 17, 2026
697104e
feat(gitGraph): restrict ClickTarget to safe keywords
mkobit May 18, 2026
8f2394b
feat(gitGraph): add runtime allowlist for link targets
mkobit May 18, 2026
5db59ec
test(gitGraph): add visual E2E snapshot for click interactions
mkobit May 19, 2026
611378d
test(gitGraph): add functional E2E tests for click interactions
mkobit May 19, 2026
b5427cf
chore(gitGraph): address review nits (required href, type safety, for…
mkobit May 19, 2026
114fe64
chore(gitGraph): remove redundant optional chaining in parser
mkobit May 19, 2026
b55f0c3
Merge branch 'develop' into feat/gitgraph-clickable-commits
mkobit May 22, 2026
8fc1130
Merge branch 'develop' into feat/gitgraph-clickable-commits
mkobit Jun 17, 2026
861cdc4
Merge branch 'develop' into feat/gitgraph-clickable-commits
mkobit Jul 3, 2026
82f24bb
Merge branch 'develop' into feat/gitgraph-clickable-commits
mkobit Jul 27, 2026
8abe37a
Merge branch 'upstream/develop' into feat/gitgraph-clickable-commits
mkobit Jul 29, 2026
b458582
test(gitGraph): add failing test for overlapping link type identifiers
mkobit Jul 29, 2026
e6bd179
fix(gitGraph): key link storage by type and id pair
mkobit Jul 29, 2026
068954a
test(gitGraph): add failing test for focus-visible CSS selectors
mkobit Jul 29, 2026
865c1d1
fix(gitGraph): add a:focus-visible selectors for keyboard navigation
mkobit Jul 29, 2026
8a3fe71
docs(gitgraph): document sandbox security level link behavior
mkobit Jul 29, 2026
91fc3a1
style(gitGraph): format gitGraphAst.ts with prettier
mkobit Jul 29, 2026
e020e3b
Merge remote-tracking branch 'upstream/develop' into feat/gitgraph-cl…
mkobit Sep 7, 2026
1ae535b
fix(gitGraph): set both SVG link attributes
mkobit Sep 7, 2026
2b6c65e
test(gitGraph): cover interactive link attributes
mkobit Sep 7, 2026
f84e6c8
test(gitGraph): port click interactions to Playwright
mkobit Sep 8, 2026
501f3a5
fix(e2e): stabilize local visual snapshots
mkobit Sep 8, 2026
f9d2ab4
fix(gitGraph): preserve branch label class
mkobit Sep 8, 2026
c8df9d7
fix(gitGraph): accept quoted click targets
mkobit Sep 9, 2026
4721a5e
docs(gitGraph): clarify click security syntax
mkobit Sep 11, 2026
a9f2c3d
Merge remote-tracking branch 'upstream/develop' into feat/gitgraph-cl…
mkobit Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dirty-corners-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': minor
---

feat: Add interactive click support for commits, branches, and tags in gitGraph
28 changes: 28 additions & 0 deletions demos/git.html
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,34 @@ <h2>Three branches and a cherry-pick from each graph</h2>
commit id:"C"
merge featureA
</pre>
<h2>Click interaction tests (click nodes/branches/tags below)</h2>
<pre class="mermaid">
gitGraph
commit id: "no-click"
commit id: "click-same-tab"
commit id: "click-new-tab" tag: "tag-new-tab"
branch "br-click-same"
checkout "br-click-same"
commit id: "reverse-type" type: REVERSE
checkout main
branch "br-click-new"
checkout "br-click-new"
commit id: "highlight-type" type: HIGHLIGHT tag: "tag-same-tab"
checkout main
merge "br-click-same" id: "merge-click"

click commit "click-same-tab" "https://example.com/click-same-tab" "same tab"
click commit "click-new-tab" "https://example.com/click-new-tab" "new tab" "_blank"
click commit "reverse-type" "https://example.com/reverse" "reverse"
click commit "highlight-type" "https://example.com/highlight" "highlight"
click commit "merge-click" "https://example.com/merge" "merge"

click branch "br-click-same" "https://example.com/br-click-same" "same tab"
click branch "br-click-new" "https://example.com/br-click-new" "new tab" "_blank"

click tag "tag-same-tab" "https://example.com/tag-same-tab" "same tab"
click tag "tag-new-tab" "https://example.com/tag-new-tab" "new tab" "_blank"
</pre>
<script type="module">
import mermaid from './mermaid.esm.mjs';
const ALLOWED_TAGS = [
Expand Down
61 changes: 61 additions & 0 deletions docs/syntax/gitgraph.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,67 @@ Let see an example:
commit id:"C"
```

## Interaction

You can add clickable links to commits, branches, and tags. This allows you to navigate to external URLs when clicking on the corresponding element in the diagram.

> **Note**
>
> - `securityLevel='strict'`: Click links are disabled.
> - `securityLevel='loose'`: Click links are enabled and respect specified targets (`_blank`, `_self`, etc.).
> - `securityLevel='sandbox'`: Click links are enabled and override the link target to `_top`.
> - `securityLevel='antiscript'`: Click links are enabled, but unsafe URL schemes are suppressed.

Usage:

- `click commit <id> "<url>"`: Adds a link to a commit with the given ID.
- `click branch <name> "<url>"`: Adds a link to a branch with the given name.
- `click tag <name> "<url>"`: Adds a link to a tag with the given name.

You can also specify an optional tooltip and target (e.g., `_blank` for new tab).

Syntax: `click <type> <id> "<url>" ["tooltip"] [target]`

Links are opened in the same browser tab/window by default. It is possible to change this by adding a link target to the click definition (`_self`, `_blank`, `_parent` and `_top` are supported):

Example:

```mermaid-example
gitGraph
commit id: "ONE"
commit id: "TWO" type: REVERSE
commit id: "THREE" type: HIGHLIGHT tag: "v1.0"
branch develop
commit id: "FOUR"
checkout main
merge develop id: "FIVE"

click commit "ONE" "https://github.com/mermaid-js/mermaid" "This is a link"
click commit "TWO" "https://www.wikipedia.org" "Open this in a new tab" _blank
click commit "THREE" "https://mermaid.js.org" "HIGHLIGHT type"
click commit "FIVE" "https://github.com/mermaid-js/mermaid/pulls" "MERGE type"
click branch "develop" "https://example.org"
click tag "v1.0" "https://github.com/mermaid-js/mermaid/releases" "Open this in a new tab" _blank
```

```mermaid
gitGraph
commit id: "ONE"
commit id: "TWO" type: REVERSE
commit id: "THREE" type: HIGHLIGHT tag: "v1.0"
branch develop
commit id: "FOUR"
checkout main
merge develop id: "FIVE"

click commit "ONE" "https://github.com/mermaid-js/mermaid" "This is a link"
click commit "TWO" "https://www.wikipedia.org" "Open this in a new tab" _blank
click commit "THREE" "https://mermaid.js.org" "HIGHLIGHT type"
click commit "FIVE" "https://github.com/mermaid-js/mermaid/pulls" "MERGE type"
click branch "develop" "https://example.org"
click tag "v1.0" "https://github.com/mermaid-js/mermaid/releases" "Open this in a new tab" _blank
```

## GitGraph specific configuration options

In Mermaid, you have the option to configure the gitgraph diagram. You can configure the following options:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
config:
securityLevel: loose
---
gitGraph
commit id: "ONE"
commit id: "TWO" tag: "v1.0"
branch develop
commit id: "THREE"
checkout main
merge develop id: "FOUR"
click commit "ONE" "https://example.com/commit" "Commit Tooltip"
click branch "develop" "https://example.com/branch" "Branch Tooltip"
click tag "v1.0" "https://example.com/tag" "Tag Tooltip"
2 changes: 1 addition & 1 deletion e2e/helpers/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ export const verifyScreenshot = async (

if (!existsSync(snapshotPath)) {
mkdirSync(dirname(snapshotPath), { recursive: true });
const screenshot = await target.screenshot({ animations: 'disabled', scale: 'device' });
const screenshot = await target.screenshot({ animations: 'disabled', scale: 'css' });
writeFileSync(snapshotPath, screenshot);
return;
}
Expand Down
33 changes: 33 additions & 0 deletions e2e/other/pr-check/interaction.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,45 @@ test.describe('Interaction', () => {
'Clicked By Gant test1 test2 test3'
);
});

test('gitGraph: should handle a click on a commit with a bound url', async ({ page }) => {
const commitLinks = page.getByRole('link', { name: 'Commit Tooltip', exact: true });
await expect(commitLinks).toHaveCount(2);
await expect(commitLinks.first()).toHaveAttribute('target', '_blank');
const popupPromise = page.waitForEvent('popup');
await commitLinks.first().click();
const popup = await popupPromise;
await expect(popup).toHaveURL('/empty.html');
});

test('gitGraph: should handle a click on a branch with a bound url', async ({ page }) => {
const branchLink = page.getByRole('link', { name: 'Branch Tooltip', exact: true });
await expect(branchLink).toHaveCount(1);
await branchLink.click();
await expect(page).toHaveURL('/empty.html');
});

test('gitGraph: should handle a click on a tag with a bound url', async ({ page }) => {
const tagLink = page.getByRole('link', { name: 'Tag Tooltip', exact: true });
await expect(tagLink).toHaveCount(1);
await tagLink.click();
await expect(page).toHaveURL('/empty.html');
});
});

test.describe('Interaction - security level tight', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/click_security_strict.html');
});

test('gitGraph: should not create interactive links in strict mode', async ({ page }) => {
const gitGraph = page.locator('svg').filter({ has: page.locator('.commit-bullets') });
await expect(gitGraph).toHaveCount(1);
await expect(gitGraph).toBeVisible();
await expect(gitGraph.locator('a')).toHaveCount(0);
await expect(gitGraph.locator('.clickable')).toHaveCount(0);
});

test('should handle a click on a node without a bound function', async ({ page }) => {
await page.locator('.node').filter({ hasText: 'Function1' }).click();
await expect(page.locator('.created-by-click')).toHaveCount(0);
Expand Down
11 changes: 11 additions & 0 deletions e2e/platform/click_security_loose.html
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@
class ShapeCallback2
click ShapeCallback2 call clickByClass(123) "This is a tooltip for a callback"
</pre>

<pre class="mermaid">
gitGraph
commit id: "ONE"
commit id: "TWO" tag: "v1.0"
branch develop
commit id: "THREE"
click commit "ONE" "/empty.html" "Commit Tooltip" "_blank"
click branch "develop" "/empty.html" "Branch Tooltip"
click tag "v1.0" "/empty.html" "Tag Tooltip"
</pre>
</div>

<script>
Expand Down
11 changes: 11 additions & 0 deletions e2e/platform/click_security_strict.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@
click 2URL "/empty.html" "Visit <strong>mermaid docs</strong>"
</pre>

<pre class="mermaid">
gitGraph
commit id: "ONE"
commit id: "TWO" tag: "v1.0"
branch develop
commit id: "THREE"
click commit "ONE" "/empty.html" "Commit Tooltip"
click branch "develop" "/empty.html" "Branch Tooltip"
click tag "v1.0" "/empty.html" "Tag Tooltip"
</pre>

<pre class="mermaid">
gantt
dateFormat YYYY-MM-DD
Expand Down
3 changes: 2 additions & 1 deletion e2e/sheet-order.json
Original file line number Diff line number Diff line change
Expand Up @@ -807,7 +807,8 @@
"94-should-render-with-parallelcommits-set-to-false.png",
"95-should-render-with-custom-mainbranchname.png",
"96-should-render-with-custom-mainbranchorder.png",
"97-should-render-branch-labels-with-multi-line-text-aligned-with-background-in-lr-layout.png"
"97-should-render-branch-labels-with-multi-line-text-aligned-with-background-in-lr-layout.png",
"98-should-render-gitgraph-with-click-interactions.png"
],
"diagrams/info": [
"should-handle-an-info-definition-with-showinfo.png",
Expand Down
11 changes: 11 additions & 0 deletions packages/mermaid/src/diagrams/git/gitGraph.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { log } from '../../logger.js';
import { db } from './gitGraphAst.js';
import { parser } from './gitGraphParser.js';
import getStyles from './styles.js';

describe('when parsing a gitGraph', function () {
beforeEach(function () {
Expand Down Expand Up @@ -1388,4 +1389,14 @@ describe('when parsing a gitGraph', function () {
expect(config).toHaveProperty('parallelCommits');
});
});

describe('getStyles', () => {
it('should include keyboard focus-visible selectors for clickable elements', () => {
const styles = getStyles({});
expect(styles).toContain('a:focus-visible .clickable text');
expect(styles).toContain('a:focus-visible .commit.clickable circle');
expect(styles).toContain('a:focus-visible .commit.clickable rect:not(.commit-label-bkg)');
expect(styles).toContain('a:focus-visible .commit.clickable path');
});
});
});
103 changes: 103 additions & 0 deletions packages/mermaid/src/diagrams/git/gitGraphAst.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { db, clear } from './gitGraphAst.js';

describe('gitGraph links', () => {
beforeEach(() => {
clear();
});

describe('setLink', () => {
it('should store a basic link', () => {
db.setLink('commit1', 'https://example.com', 'commit');
expect(db.getLinks().has('commit:commit1')).toBe(true);
expect(db.getLink('commit1')?.link).toBe('https://example.com');
});

it('should default target to _self', () => {
db.setLink('commit1', 'https://example.com', 'commit');
expect(db.getLink('commit1')?.target).toBe('_self');
});

it('should store tooltip when provided', () => {
db.setLink('commit1', 'https://example.com', 'commit', 'My tooltip');
expect(db.getLink('commit1')?.tooltip).toBe('My tooltip');
});

it('should store target when provided', () => {
db.setLink('commit1', 'https://example.com', 'commit', undefined, '_blank');
expect(db.getLink('commit1')?.target).toBe('_blank');
});

it('should store all options together', () => {
db.setLink('commit1', 'https://example.com', 'commit', 'Tooltip', '_blank');
const link = db.getLink('commit1');
expect(link?.link).toBe('https://example.com');
expect(link?.tooltip).toBe('Tooltip');
expect(link?.target).toBe('_blank');
});

it('should overwrite existing link for same id', () => {
db.setLink('commit1', 'https://old.com', 'commit');
db.setLink('commit1', 'https://new.com', 'commit');
expect(db.getLink('commit1')?.link).toBe('https://new.com');
});

it('should handle special characters in commit id', () => {
db.setLink('v1.0.0-beta', 'https://example.com', 'commit');
expect(db.getLink('v1.0.0-beta')?.link).toBe('https://example.com');
});

it('should handle overlapping identifiers across commit, branch, and tag types', () => {
db.setLink('v1.0', 'https://example.com/commit', 'commit');
db.setLink('v1.0', 'https://example.com/branch', 'branch');
db.setLink('v1.0', 'https://example.com/tag', 'tag');

expect((db.getLink as any)('v1.0', 'commit')?.link).toBe('https://example.com/commit');
expect((db.getLink as any)('v1.0', 'branch')?.link).toBe('https://example.com/branch');
expect((db.getLink as any)('v1.0', 'tag')?.link).toBe('https://example.com/tag');
});
});

describe('getLinks', () => {
it('should return empty map when no links', () => {
expect(db.getLinks().size).toBe(0);
});

it('should return all stored links', () => {
db.setLink('c1', 'https://example.com/1', 'commit');
db.setLink('c2', 'https://example.com/2', 'commit');
const links = db.getLinks();
expect(links.size).toBe(2);
expect(links.has('commit:c1')).toBe(true);
expect(links.has('commit:c2')).toBe(true);
});

it('should return the internal map (performance)', () => {
db.setLink('c1', 'https://example.com', 'commit');
const links1 = db.getLinks();
const links2 = db.getLinks();
expect(links1).toBe(links2);
});
});

describe('getLink', () => {
it('should return undefined for non-existent id', () => {
expect(db.getLink('nonexistent')).toBeUndefined();
});

it('should return link data for existing id', () => {
db.setLink('c1', 'https://example.com', 'commit');
const link = db.getLink('c1');
expect(link).toBeDefined();
expect(link?.id).toBe('c1');
});
});

describe('clear', () => {
it('should clear all links', () => {
db.setLink('c1', 'https://example.com', 'commit');
db.setLink('c2', 'https://example.com', 'commit');
clear();
expect(db.getLinks().size).toBe(0);
});
});
});
Loading
Loading