Skip to content

Commit 5a8eae7

Browse files
committed
Merge branch 'master' into release/11.16.0
Fixes conflicts due to icon dependency changes in `master` from 5b5864a (chore(MC-4488): Add landing popup, 2026-04-23). See: 5b5864a Conflicts: - packages/mermaid/src/docs/package.json - pnpm-lock.yaml
2 parents dd5ea77 + a047cbf commit 5a8eae7

8 files changed

Lines changed: 414 additions & 64 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<script setup lang="ts">
2+
import { onMounted, ref, type Component } from 'vue';
3+
import DocsIcon from '~icons/material-symbols/docs-outline-rounded';
4+
import ArrowForwardIcon from '~icons/material-symbols/arrow-forward-rounded';
5+
import CodeIcon from '~icons/material-symbols/code-blocks-outline';
6+
import EditIcon from '~icons/material-symbols/rebase-edit-outline-rounded';
7+
import {
8+
landingPopUpStorageKey,
9+
markLandingPopUpSeen,
10+
type LandingPopUpChoice,
11+
shouldShowLandingPopUp,
12+
} from '../landingPopUp.js';
13+
import { trackPlausibleEvent } from '../theme/plausible.js';
14+
15+
const isVisible = ref(false);
16+
17+
type LandingOption = {
18+
choice: Exclude<LandingPopUpChoice, 'skip'>;
19+
title: string;
20+
description: string;
21+
cta: string;
22+
rowClass: string;
23+
buttonClass: string;
24+
icon: Component;
25+
};
26+
27+
const links: Record<Exclude<LandingPopUpChoice, 'skip'>, string> = {
28+
'full-editor':
29+
'https://mermaid.ai/app/sign-up?utm_source=mermaid_js&utm_medium=landing_pop_up&utm_campaign=editor_v1',
30+
'browse-docs':
31+
'https://mermaid.ai/open-source/intro/index.html?utm_source=mermaid_js&utm_medium=landing_pop_up&utm_campaign=docs_v1',
32+
'live-editor': 'https://mermaid.live',
33+
};
34+
35+
const options: LandingOption[] = [
36+
{
37+
choice: 'full-editor',
38+
title: "Mermaid's full editor",
39+
description: 'Render existing diagrams or build with AI, code, drag-and-drop, or voice.',
40+
cta: 'Start free',
41+
rowClass: 'border-[#e80962]',
42+
buttonClass: 'bg-[#e80962] text-white group-hover:bg-[#ff1e7a]',
43+
icon: EditIcon,
44+
},
45+
{
46+
choice: 'browse-docs',
47+
title: 'Browse the docs',
48+
description: 'Mermaid syntax reference, diagram guides, and contributor docs.',
49+
cta: 'Read docs',
50+
rowClass: 'border-[#2b2542]',
51+
buttonClass: 'bg-[#332a54] text-[#ddedf0] group-hover:bg-[#3f3568]',
52+
icon: DocsIcon,
53+
},
54+
{
55+
choice: 'live-editor',
56+
title: 'Code in the live editor',
57+
description: 'Write Mermaid syntax with live preview — no account needed.',
58+
cta: 'Mermaid.live',
59+
rowClass: 'border-[#2b2542]',
60+
buttonClass: 'bg-[#332a54] text-[#ddedf0] group-hover:bg-[#3f3568]',
61+
icon: CodeIcon,
62+
},
63+
];
64+
65+
const rememberSeen = () => {
66+
markLandingPopUpSeen((value) => {
67+
window.localStorage.setItem(landingPopUpStorageKey, value);
68+
});
69+
};
70+
71+
const handleChoice = (choice: LandingPopUpChoice) => {
72+
void trackPlausibleEvent('landingPopUp', { props: { choice } });
73+
isVisible.value = false;
74+
rememberSeen();
75+
76+
if (choice === 'skip') {
77+
return;
78+
}
79+
80+
window.location.href = links[choice];
81+
};
82+
83+
onMounted(() => {
84+
const visible = shouldShowLandingPopUp({
85+
hostname: window.location.hostname,
86+
referrer: document.referrer,
87+
getLastSeen: () => window.localStorage.getItem(landingPopUpStorageKey),
88+
});
89+
90+
if (!visible) {
91+
return;
92+
}
93+
94+
isVisible.value = true;
95+
});
96+
</script>
97+
98+
<template>
99+
<div
100+
v-if="isVisible"
101+
class="fixed inset-0 z-50 flex items-center justify-center bg-[#070916]/70 p-4"
102+
@click.self="handleChoice('skip')"
103+
>
104+
<div
105+
class="w-full max-w-[654px] rounded-2xl border border-[#374151] bg-[#1a1625] p-8 text-white shadow-[0_20px_80px_rgba(0,0,0,0.55)]"
106+
>
107+
<div class="flex flex-col items-center text-center">
108+
<img src="/favicon.svg" alt="Mermaid" class="h-[41px] w-[41px] rounded-[8px]" />
109+
<div class="h-2 w-px" />
110+
<h2 class="mt-2 text-2xl font-bold text-white">What are you looking for?</h2>
111+
</div>
112+
113+
<div class="mt-5 grid gap-3">
114+
<button
115+
v-for="option in options"
116+
:key="option.choice"
117+
class="group flex items-center gap-3 overflow-hidden rounded-lg border bg-transparent py-[14px] pl-3 pr-2 text-left transition-colors hover:border-[#f84594] hover:bg-[#211c31] hover:shadow-[0_0_0_1px_#f84594]"
118+
:class="option.rowClass"
119+
@click="handleChoice(option.choice)"
120+
>
121+
<div class="flex h-5 w-5 shrink-0 items-center justify-center text-[#f5f5f5]">
122+
<component :is="option.icon" class="h-5 w-5" />
123+
</div>
124+
125+
<div class="min-w-0 flex-1">
126+
<div class="text-[14px] font-medium text-white">{{ option.title }}</div>
127+
<p class="mt-0.5 text-xs font-light leading-[1.2] text-[#9ca3af]">
128+
{{ option.description }}
129+
</p>
130+
</div>
131+
132+
<div class="flex shrink-0 items-center pl-2">
133+
<span
134+
class="inline-flex items-center gap-2 rounded-[12px] px-4 py-2 text-sm font-semibold transition-colors"
135+
:class="option.buttonClass"
136+
>
137+
{{ option.cta }}
138+
<ArrowForwardIcon class="h-4 w-4" />
139+
</span>
140+
</div>
141+
</button>
142+
</div>
143+
144+
<div class="mt-6 text-center text-xs text-[#6b7280]">
145+
<button class="underline" @click="handleChoice('skip')">
146+
Just exploring, skip for now
147+
</button>
148+
<p class="mt-3">The full editor is part of Mermaid.ai. Start free today.</p>
149+
</div>
150+
</div>
151+
</div>
152+
</template>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
landingPopUpStorageKey,
4+
markLandingPopUpSeen,
5+
shouldShowLandingPopUp,
6+
} from './landingPopUp.js';
7+
8+
describe('landingPopUp', () => {
9+
it('shows on mermaid.js.org when never seen', () => {
10+
const visible = shouldShowLandingPopUp({
11+
hostname: 'mermaid.js.org',
12+
getLastSeen: () => null,
13+
});
14+
15+
expect(visible).toBe(true);
16+
});
17+
18+
it('shows on localhost when never seen', () => {
19+
const visible = shouldShowLandingPopUp({
20+
hostname: 'localhost',
21+
getLastSeen: () => null,
22+
});
23+
24+
expect(visible).toBe(true);
25+
});
26+
27+
it('does not show on non-mermaid.js hosts', () => {
28+
const visible = shouldShowLandingPopUp({
29+
hostname: 'mermaid.ai',
30+
getLastSeen: () => null,
31+
});
32+
33+
expect(visible).toBe(false);
34+
});
35+
36+
it('does not show when coming from mermaid.live', () => {
37+
const visible = shouldShowLandingPopUp({
38+
hostname: 'mermaid.js.org',
39+
referrer: 'https://mermaid.live/edit',
40+
getLastSeen: () => null,
41+
});
42+
43+
expect(visible).toBe(false);
44+
});
45+
46+
it('respects a 24 hour cooldown', () => {
47+
const now = Date.now();
48+
const recent = now - 23 * 60 * 60 * 1000;
49+
50+
const visible = shouldShowLandingPopUp({
51+
hostname: 'mermaid.js.org',
52+
now,
53+
getLastSeen: () => String(recent),
54+
});
55+
56+
expect(visible).toBe(false);
57+
});
58+
59+
it('shows again after 24 hours', () => {
60+
const now = Date.now();
61+
const stale = now - 24 * 60 * 60 * 1000;
62+
63+
const visible = shouldShowLandingPopUp({
64+
hostname: 'mermaid.js.org',
65+
now,
66+
getLastSeen: () => String(stale),
67+
});
68+
69+
expect(visible).toBe(true);
70+
});
71+
72+
it('records seen timestamp', () => {
73+
const writes: Record<string, string> = {};
74+
markLandingPopUpSeen((value: string) => {
75+
writes[landingPopUpStorageKey] = value;
76+
}, 12345);
77+
78+
expect(writes[landingPopUpStorageKey]).toBe('12345');
79+
});
80+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
export const landingPopUpStorageKey = 'mermaid-landing-pop-up-last-seen';
2+
export type LandingPopUpChoice = 'full-editor' | 'browse-docs' | 'live-editor' | 'skip';
3+
4+
const isEligibleHost = (hostname: string) => {
5+
return (
6+
hostname === 'localhost' ||
7+
hostname === 'mermaid.js.org' ||
8+
hostname.endsWith('.mermaid.js.org')
9+
);
10+
};
11+
12+
const isMermaidLiveReferrer = (referrer: string | undefined) => {
13+
if (!referrer) {
14+
return false;
15+
}
16+
17+
try {
18+
const referrerHost = new URL(referrer).hostname;
19+
return referrerHost === 'mermaid.live' || referrerHost.endsWith('.mermaid.live');
20+
} catch {
21+
return false;
22+
}
23+
};
24+
25+
export const shouldShowLandingPopUp = (params: {
26+
hostname: string;
27+
referrer?: string;
28+
now?: number;
29+
getLastSeen: () => string | null;
30+
}) => {
31+
if (!isEligibleHost(params.hostname)) {
32+
return false;
33+
}
34+
if (isMermaidLiveReferrer(params.referrer)) {
35+
return false;
36+
}
37+
38+
const lastSeenValue = params.getLastSeen();
39+
if (!lastSeenValue) {
40+
return true;
41+
}
42+
43+
const lastSeen = Number(lastSeenValue);
44+
if (!Number.isFinite(lastSeen)) {
45+
return true;
46+
}
47+
48+
const modalTtlMs = 24 * 60 * 60 * 1000;
49+
return params.now !== undefined
50+
? params.now - lastSeen >= modalTtlMs
51+
: Date.now() - lastSeen >= modalTtlMs;
52+
};
53+
54+
export const markLandingPopUpSeen = (setLastSeen: (value: string) => void, now = Date.now()) => {
55+
setLastSeen(String(now));
56+
};

packages/mermaid/src/docs/.vitepress/theme/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import DefaultTheme from 'vitepress/theme';
33
import Contributors from '../components/Contributors.vue';
44
import EditorSelectionModal from '../components/EditorSelectionModal.vue';
55
import HomePage from '../components/HomePage.vue';
6+
import LandingIntentModal from '../components/LandingIntentModal.vue';
67
import TopBar from '../components/TopBar.vue';
78
import './custom.css';
89
import Mermaid from './Mermaid.vue';
@@ -26,7 +27,7 @@ export default {
2627
'home-hero-after': () => h(OssHomeHeroNameClipApplier),
2728
'doc-before': () => h(TopBar),
2829
'layout-bottom': () => h(Tooltip),
29-
'layout-top': () => h(EditorSelectionModal),
30+
'layout-top': () => [h(EditorSelectionModal), h(LandingIntentModal)],
3031
});
3132
},
3233
enhanceApp({ app, router }: EnhanceAppContext) {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="unplugin-icons/types/vue" />

packages/mermaid/src/docs/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@
2626
},
2727
"devDependencies": {
2828
"@iconify-json/carbon": "^1.2.20",
29+
"@iconify-json/material-symbols": "^1.2.67",
2930
"@unocss/reset": "^66.5.12",
3031
"@vite-pwa/vitepress": "^1.0.1",
3132
"@vitejs/plugin-vue": "^6.0.6",
3233
"fast-glob": "^3.3.3",
3334
"https-localhost": "^4.7.1",
3435
"pathe": "^2.0.3",
3536
"unocss": "^66.5.12",
37+
"unplugin-icons": "^23.0.1",
3638
"unplugin-vue-components": "^28.8.0",
3739
"vite": "^7.3.3",
3840
"vite-plugin-pwa": "^1.0.3",

packages/mermaid/src/docs/vite.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from 'path';
55
import { SearchPlugin } from 'vitepress-plugin-search';
66
import fs from 'fs';
77
import Components from 'unplugin-vue-components/vite';
8+
import Icons from 'unplugin-icons/vite';
89
import Unocss from 'unocss/vite';
910
import { presetAttributify, presetIcons, presetUno } from 'unocss';
1011
import { resolve } from 'pathe';
@@ -32,6 +33,9 @@ export default defineConfig({
3233
dirs: '.vitepress/components',
3334
dts: '.vitepress/components.d.ts',
3435
}) as Plugin,
36+
Icons({
37+
compiler: 'vue3',
38+
}) as Plugin,
3539
// @ts-ignore This package has an incorrect exports.
3640
Unocss({
3741
shortcuts: [

0 commit comments

Comments
 (0)