chore: Auto redirect to new domain

This commit is contained in:
Sidharth Vinod
2026-03-03 13:22:36 +05:30
parent 94f7dafc72
commit d092480cf3
11 changed files with 443 additions and 20 deletions
-6
View File
@@ -1,6 +0,0 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event, {});
return response;
};
+2 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { env } from '$/util/env';
import { isOnMermaidAI } from '$/util/migration/domainMigration';
import type { ComponentProps } from 'svelte';
import ExternalLinkWrapper from './ExternalLinkWrapper.svelte';
@@ -12,13 +13,12 @@
> = $props();
const mermaidChartDomain = 'mermaid.ai';
const isMermaidAiDomain = env.domain === mermaidChartDomain;
</script>
<ExternalLinkWrapper
{...props}
domain={mermaidChartDomain}
isVisible={env.isEnabledMermaidChartLinks}
showPopup={!isMermaidAiDomain}>
showPopup={!isOnMermaidAI()}>
{@render children()}
</ExternalLinkWrapper>
+4 -3
View File
@@ -21,9 +21,10 @@
interface Props {
mobileToggle?: Snippet;
children: Snippet;
hidePromotion?: boolean;
}
let { children, mobileToggle }: Props = $props();
let { children, mobileToggle, hidePromotion = false }: Props = $props();
type Links = ComponentProps<typeof DropdownNavMenu>['links'];
@@ -39,7 +40,7 @@
}
];
let activePromotion = $state(getActivePromotion());
let activePromotion = $state(hidePromotion ? undefined : getActivePromotion());
const trackBannerClick = () => {
if (!plausible || !activePromotion) {
@@ -54,7 +55,7 @@
{#if activePromotion}
<div class="top-bar z-10 flex h-fit w-full bg-primary">
<div
class="flex flex-grow"
class="flex grow"
role="button"
tabindex="0"
onclick={trackBannerClick}
@@ -0,0 +1,84 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import {
dismissWelcomeBanner,
incrementWelcomeBannerVisitCount,
redirectToMermaidLive
} from '$/util/migration/domainMigration';
import { logEvent } from '$/util/stats';
import { onMount } from 'svelte';
import CloseIcon from '~icons/material-symbols/close-rounded';
import SwapIcon from '~icons/material-symbols/swap-horiz-rounded';
import WhyWeMovedModal from './WhyWeMovedModal.svelte';
let visible = $state(true);
let showExplainer = $state(false);
onMount(() => {
incrementWelcomeBannerVisitCount();
});
const handleContinue = () => {
logEvent('bannerDismissed');
dismissWelcomeBanner();
visible = false;
};
const handleClose = () => {
// Just dismiss for this session, don't set permanent dismissal
logEvent('bannerDismissed');
visible = false;
};
const handleGoBack = () => {
redirectToMermaidLive();
};
const handleWhyDidWeMove = () => {
logEvent('explainerOpened');
showExplainer = true;
};
</script>
{#if visible}
<div
class="relative mb-2 flex w-full flex-col items-center gap-3 border-b bg-pink-100 px-4 py-3 sm:flex-row sm:gap-4"
role="banner">
<div class="flex flex-1 flex-col items-center gap-2 text-center sm:flex-row sm:text-left">
<SwapIcon class="hidden size-5 shrink-0 text-accent sm:block" />
<div>
<span class="font-medium">Mermaid Live Editor has a new home</span>
<span class="hidden text-sm text-muted-foreground sm:inline">
Same open-source editor, your diagrams stay client-side.
</span>
<button
type="button"
class="ml-1 text-sm text-blue-500 underline hover:text-blue-400"
onclick={handleWhyDidWeMove}>
Why did we move?
</button>
</div>
</div>
<div class="flex items-center gap-3">
<Button variant="accent" size="sm" onclick={handleContinue}>Continue here</Button>
<span class="text-sm text-muted-foreground">or</span>
<button
type="button"
class="text-sm text-blue-500 underline hover:text-blue-400"
onclick={handleGoBack}>
go back to mermaid.live
</button>
</div>
<button
type="button"
class="absolute top-2 right-2 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground sm:static sm:ml-2"
onclick={handleClose}
title="Dismiss">
<CloseIcon class="size-4" />
</button>
</div>
{/if}
<WhyWeMovedModal bind:open={showExplainer} onOpenChange={(v) => (showExplainer = v)} />
@@ -0,0 +1,73 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import { redirectToMermaidLive } from '$/util/migration/domainMigration';
import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
import ShieldIcon from '~icons/material-symbols/shield-lock-outline-rounded';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
let { open = $bindable(), onOpenChange }: Props = $props();
const handleUseMermaidLive = () => {
redirectToMermaidLive();
};
const handleGotIt = () => {
onOpenChange(false);
};
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content class="max-w-lg p-8">
<Dialog.Header>
<Dialog.Title class="text-2xl font-semibold">Why the new address?</Dialog.Title>
</Dialog.Header>
<div class="mt-4 space-y-4">
<p class="text-muted-foreground">
We're consolidating the Mermaid ecosystem under mermaid.ai — one home for the editor, docs,
and the commercial platform.
</p>
<div class="space-y-4 border-t pt-4">
<div class="flex items-start gap-3">
<ShieldIcon class="mt-0.5 size-5 shrink-0 text-green-600" />
<div>
<p class="font-medium">Same privacy</p>
<p class="text-sm text-muted-foreground">
Diagrams render client-side. No data sent to servers.
</p>
</div>
</div>
<div class="flex items-start gap-3">
<BookIcon class="mt-0.5 size-5 shrink-0 text-blue-600" />
<div>
<p class="font-medium">Same open-source code</p>
<p class="text-sm text-muted-foreground">Identical codebase. MIT licensed.</p>
</div>
</div>
<div class="flex items-start gap-3">
<ExternalLinkIcon class="mt-0.5 size-5 shrink-0 text-purple-600" />
<div>
<p class="font-medium">Old links still work</p>
<p class="text-sm text-muted-foreground">
All mermaid.live URLs with diagrams keep working.
</p>
</div>
</div>
</div>
</div>
<Dialog.Footer class="mt-6 flex gap-3">
<Button variant="outline" onclick={handleUseMermaidLive}>Use mermaid.live</Button>
<Button variant="accent" onclick={handleGotIt}>Got it</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+233
View File
@@ -0,0 +1,233 @@
import { logEvent } from '$/util/stats';
// Constants for the domain migration
const mermaidLiveDomain = 'mermaid.live';
const mermaidAiDomain = 'mermaid.ai';
const bypassCookieName = 'mermaid-stay';
const welcomeBannerStorageKey = 'mermaid-welcome-banner-dismissed';
const welcomeBannerVisitCountKey = 'mermaid-welcome-banner-visits';
const redirectReferrerKey = 'mermaid-redirect-from';
/**
* Check if the current URL has pako data (diagram content).
* Pako data is in the hash fragment as #pako:... or as a legacy format.
*/
const hasPakoData = (): boolean => {
const hash = window.location.hash;
return (
hash.includes('pako:') || hash.includes('base64:') || (hash.length > 10 && hash.startsWith('#'))
);
};
/**
* Get the current domain
*/
const getCurrentDomain = (): string => {
return window.location.hostname;
};
/**
* Check if we're on mermaid.live
*/
const isOnMermaidLive = (): boolean => {
return (
getCurrentDomain() === mermaidLiveDomain || getCurrentDomain().endsWith(`.${mermaidLiveDomain}`)
);
};
/**
* Check if we're on mermaid.ai
*/
export const isOnMermaidAI = (): boolean => {
const domain = getCurrentDomain();
return domain === mermaidAiDomain || domain.endsWith(`.${mermaidAiDomain}`);
};
/**
* Get a cookie value by name
*/
const getCookie = (name: string): string | null => {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? match[2] : null;
};
/**
* Set a cookie
*/
const setCookie = (name: string, value: string, days: number): void => {
const expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`;
};
/**
* Check if the bypass cookie is set (user opted to stay on mermaid.live)
*/
const hasBypassCookie = (): boolean => {
return getCookie(bypassCookieName) === '1';
};
/**
* Set the bypass cookie (user opted to stay on mermaid.live)
* Cookie is permanent (100 years) so user doesn't have to opt out repeatedly
*/
const setBypassCookie = (): void => {
setCookie(bypassCookieName, '1', 36500); // ~100 years
};
/**
* Redirect to mermaid.live with ?stay=1 to trigger bypass cookie on that domain.
* Used by escape hatch buttons in WelcomeBanner and WhyWeMovedModal.
*/
export const redirectToMermaidLive = (): void => {
logEvent('bannerEscapeClicked');
window.location.href = `https://${mermaidLiveDomain}/edit?stay=1`;
};
/**
* Check if the URL has the ?stay=1 parameter (used for escape hatch)
*/
const hasStayParam = (): boolean => {
const params = new URLSearchParams(window.location.search);
return params.has('stay');
};
/**
* Handle the ?stay=1 parameter: set bypass cookie and redirect to clean URL
*/
export const handleStayParam = (): boolean => {
if (hasStayParam()) {
logEvent('stayOnMermaidLive');
setBypassCookie();
// Remove the ?stay=1 param from URL
const url = new URL(window.location.href);
url.searchParams.delete('stay');
window.history.replaceState({}, '', url.pathname + url.hash);
return true;
}
return false;
};
/**
* Get welcome banner visit count from localStorage
*/
const getWelcomeBannerVisitCount = (): number => {
const count = window.localStorage.getItem(welcomeBannerVisitCountKey);
return count ? parseInt(count, 10) : 0;
};
/**
* Increment welcome banner visit count
*/
export const incrementWelcomeBannerVisitCount = (): number => {
const count = getWelcomeBannerVisitCount() + 1;
window.localStorage.setItem(welcomeBannerVisitCountKey, count.toString());
return count;
};
/**
* Check if welcome banner should be shown (shown for first 3 visits, then auto-hide)
*/
export const shouldShowWelcomeBanner = (): boolean => {
// Only show on mermaid.ai
if (!isOnMermaidAI()) return false;
// Check if banner was manually dismissed permanently
if (window.localStorage.getItem(welcomeBannerStorageKey) === 'true') return false;
// Check if redirect referrer was from mermaid.live
if (window.sessionStorage.getItem(redirectReferrerKey) !== 'mermaid.live') return false;
// Show for first 3 visits
return getWelcomeBannerVisitCount() < 3;
};
/**
* Mark redirect origin (call this when redirecting from mermaid.live)
*/
const setRedirectReferrer = (): void => {
window.sessionStorage.setItem(redirectReferrerKey, 'mermaid.live');
};
/**
* Dismiss the welcome banner permanently
*/
export const dismissWelcomeBanner = (): void => {
window.localStorage.setItem(welcomeBannerStorageKey, 'true');
};
// localStorage keys used by the app for user data
const userDataStorageKeys = [
'codeStore', // Current diagram code/state
'manualHistoryStore', // Manual history entries
'autoHistoryStore' // Auto history entries
];
/**
* Check if user has any stored data in localStorage.
* This includes saved diagrams, history entries, etc.
*/
const hasStoredUserData = (): boolean => {
for (const key of userDataStorageKeys) {
const value = window.localStorage.getItem(key);
if (value && value !== '[]' && value !== 'null' && value !== '{}') {
return true;
}
}
return false;
};
/**
* Check and perform domain migration redirect if needed.
* This should be called early in the app lifecycle.
*
* Redirect logic:
* - If on mermaid.live with no pako data in URL, no bypass cookie, and no stored data → redirect to mermaid.ai/live
* - If URL has pako data → stay (user followed a shared link)
* - If bypass cookie is set → stay (user opted out of redirect)
* - If user has stored data in localStorage → stay (user has history/diagrams saved locally)
*
* @returns true if a redirect was triggered, false otherwise
*/
export const checkAndRedirectIfNeeded = (): boolean => {
// Only redirect from mermaid.live
if (!isOnMermaidLive()) return false;
// Don't redirect if user has bypass cookie
if (hasBypassCookie()) return false;
// Don't redirect if URL has pako data (shared link)
if (hasPakoData()) return false;
// Don't redirect if user has stored data in localStorage (history, saved diagrams, etc.)
if (hasStoredUserData()) return false;
// Perform the redirect to mermaid.ai/live
// Set a sessionStorage flag so we know this user came from mermaid.live
// (sessionStorage doesn't transfer cross-domain, so we'll use a query param instead)
const currentPath = window.location.pathname;
const targetPath = currentPath.replace(/^\/?/, '/live/');
const redirectUrl = `https://${mermaidAiDomain}${targetPath}?from=mermaid.live`;
window.location.href = redirectUrl;
return true;
};
/**
* Handle incoming redirect from mermaid.live to mermaid.ai.
* Checks for the ?from=mermaid.live param and sets session storage.
*/
export const handleIncomingRedirect = (): void => {
// Only on mermaid.ai
if (!isOnMermaidAI()) return;
const params = new URLSearchParams(window.location.search);
if (params.get('from') === 'mermaid.live') {
// Mark that this session originated from a redirect
setRedirectReferrer();
// Clean up the URL
params.delete('from');
const cleanSearch = params.toString();
const newUrl =
window.location.pathname + (cleanSearch ? `?${cleanSearch}` : '') + window.location.hash;
window.history.replaceState({}, '', newUrl);
}
};
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import { env } from '$/util/env';
import { isOnMermaidAI } from '$/util/migration/domainMigration';
import type { Snippet } from 'svelte';
interface Props {
@@ -9,7 +9,7 @@
let { closeBanner }: Props = $props();
const utmSource = env.domain === 'mermaid.ai' ? 'mermaid_ai_live' : 'mermaid_live_editor';
const utmSource = isOnMermaidAI() ? 'mermaid_ai_live' : 'mermaid_live_editor';
const url = `https://mermaid.ai/app/user/billing/checkout?${new URLSearchParams({
coupon: 'dmIuNbqx',
+8
View File
@@ -80,18 +80,26 @@ const minutesToMilliSeconds = (minutes: number): number => {
const defaultDelay = minutesToMilliSeconds(1);
const delaysPerEvent = {
bannerClick: defaultDelay,
bannerDismissed: defaultDelay,
bannerEscapeClicked: defaultDelay,
copyClipboard: defaultDelay,
copyMarkdown: defaultDelay,
download: defaultDelay,
explainerOpened: defaultDelay,
history: defaultDelay,
loadGist: defaultDelay,
loadSampleDiagram: defaultDelay,
migration: defaultDelay,
mobileViewToggle: defaultDelay,
nudgeClicked: defaultDelay,
nudgeDismissed: defaultDelay,
panZoom: minutesToMilliSeconds(10),
pwaInstalled: defaultDelay,
// Domain migration events (mermaid.live → mermaid.ai)
redirectTriggered: defaultDelay,
render: minutesToMilliSeconds(5),
renderDiagram: defaultDelay,
stayOnMermaidLive: defaultDelay,
themeChange: defaultDelay,
version: defaultDelay
} as const;
+2 -1
View File
@@ -2,6 +2,7 @@ import { C } from '$/constants';
import { env } from './env';
import { loadDataFromUrl } from './fileLoaders/loader';
import { initLoading } from './loading';
import { isOnMermaidAI } from './migration/domainMigration';
import { applyMigrations } from './migrations';
import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state';
import { initAnalytics, plausible } from './stats';
@@ -91,7 +92,7 @@ function fallbackCopyToClipboard(text: string) {
}
export const getUTMSource = (): string => {
if (typeof window !== 'undefined' && window.location.host.includes('mermaid.ai')) {
if (typeof window !== 'undefined' && isOnMermaidAI()) {
return C.aiLiveEditor;
}
return C.utmSource;
+20 -1
View File
@@ -1,6 +1,11 @@
<script lang="ts">
import { Toaster } from '$/components/ui/sonner/index.js';
import { loadingStateStore } from '$/util/loading';
import {
checkAndRedirectIfNeeded,
handleIncomingRedirect,
handleStayParam
} from '$/util/migration/domainMigration';
import { toggleDarkTheme } from '$/util/state';
import { initHandler } from '$/util/util';
import { base } from '$app/paths';
@@ -17,6 +22,20 @@
// This can be removed once https://github.com/sveltejs/kit/issues/1612 is fixed.
// Then move it into src and vite will bundle it automatically.
onMount(() => {
// Domain migration: Handle ?stay=1 escape hatch parameter first
// This sets a bypass cookie and cleans the URL
handleStayParam();
// Domain migration: Check for redirects early in the lifecycle
// On mermaid.live: redirect to mermaid.ai if no pako data and no bypass cookie
// On mermaid.ai: handle incoming redirects from mermaid.live
const wasRedirected = checkAndRedirectIfNeeded();
if (wasRedirected) {
// If we're redirecting, don't continue with the rest of the setup
return;
}
handleIncomingRedirect();
window.addEventListener('hashchange', () => {
void initHandler();
});
@@ -41,7 +60,7 @@
<ModeWatcher />
<Toaster />
<main class="h-[100dvh]">
<main class="h-dvh">
{@render children()}
</main>
+15 -5
View File
@@ -6,6 +6,7 @@
import History from '$/components/History/History.svelte';
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import WelcomeBanner from '$/components/migration/WelcomeBanner.svelte';
import Navbar from '$/components/Navbar.svelte';
import PanZoomToolbar from '$/components/PanZoomToolbar.svelte';
import Preset from '$/components/Preset.svelte';
@@ -18,6 +19,7 @@
import VersionSecurityToolbar from '$/components/VersionSecurityToolbar.svelte';
import View from '$/components/View.svelte';
import type { EditorMode, Tab } from '$/types';
import { isOnMermaidAI, shouldShowWelcomeBanner } from '$/util/migration/domainMigration';
import { PanZoomState } from '$/util/panZoom';
import { stateStore, updateCodeStore, urlsStore } from '$/util/state';
import { logEvent } from '$/util/stats';
@@ -50,12 +52,18 @@
let width = $state(0);
let isMobile = $derived(width < 640);
let isViewMode = $state(true);
let showWelcomeBanner = $state(false);
onMount(async () => {
await initHandler();
window.addEventListener('appinstalled', () => {
logEvent('pwaInstalled', { isMobile });
});
// Check if we should show welcome banner on mermaid.ai
if (isOnMermaidAI() && shouldShowWelcomeBanner()) {
showWelcomeBanner = true;
}
});
let isHistoryOpen = $state(false);
@@ -81,7 +89,7 @@
</div>
{/snippet}
<Navbar mobileToggle={isMobile ? mobileToggle : undefined}>
<Navbar mobileToggle={isMobile ? mobileToggle : undefined} hidePromotion={showWelcomeBanner}>
<Toggle bind:pressed={isHistoryOpen} size="sm">
<HistoryIcon />
</Toggle>
@@ -98,6 +106,11 @@
</McWrapper>
</Navbar>
<!-- Domain migration: Welcome banner for mermaid.ai visitors redirected from mermaid.live -->
{#if showWelcomeBanner}
<WelcomeBanner />
{/if}
<div class="flex flex-1 flex-col overflow-hidden" bind:clientWidth={width}>
<div
class={[
@@ -137,10 +150,7 @@
</Resizable.Pane>
{#if isHistoryOpen}
<Resizable.Handle class="ml-1 hidden opacity-0 sm:block" />
<Resizable.Pane
minSize={15}
defaultSize={30}
class="hidden h-full flex-grow flex-col sm:flex">
<Resizable.Pane minSize={15} defaultSize={30} class="hidden h-full grow flex-col sm:flex">
<History />
</Resizable.Pane>
{/if}