From d092480cf37367eb2016cba12b16f3839cfcbf8c Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Tue, 3 Mar 2026 13:22:36 +0530 Subject: [PATCH 1/4] chore: Auto redirect to new domain --- src/hooks.server.ts | 6 - src/lib/components/McWrapper.svelte | 4 +- src/lib/components/Navbar.svelte | 7 +- .../components/migration/WelcomeBanner.svelte | 84 +++++++ .../migration/WhyWeMovedModal.svelte | 73 ++++++ src/lib/util/migration/domainMigration.ts | 233 ++++++++++++++++++ src/lib/util/promos/NewYear2026.svelte | 4 +- src/lib/util/stats.ts | 8 + src/lib/util/util.ts | 3 +- src/routes/+layout.svelte | 21 +- src/routes/edit/+page.svelte | 20 +- 11 files changed, 443 insertions(+), 20 deletions(-) delete mode 100644 src/hooks.server.ts create mode 100644 src/lib/components/migration/WelcomeBanner.svelte create mode 100644 src/lib/components/migration/WhyWeMovedModal.svelte create mode 100644 src/lib/util/migration/domainMigration.ts diff --git a/src/hooks.server.ts b/src/hooks.server.ts deleted file mode 100644 index 49af22b0..00000000 --- a/src/hooks.server.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Handle } from '@sveltejs/kit'; - -export const handle: Handle = async ({ event, resolve }) => { - const response = await resolve(event, {}); - return response; -}; diff --git a/src/lib/components/McWrapper.svelte b/src/lib/components/McWrapper.svelte index 50911d37..38f8848b 100644 --- a/src/lib/components/McWrapper.svelte +++ b/src/lib/components/McWrapper.svelte @@ -1,5 +1,6 @@ + showPopup={!isOnMermaidAI()}> {@render children()} diff --git a/src/lib/components/Navbar.svelte b/src/lib/components/Navbar.svelte index 32be0b79..7607fcde 100644 --- a/src/lib/components/Navbar.svelte +++ b/src/lib/components/Navbar.svelte @@ -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['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}
+ 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; + }; + + +{#if visible} + +{/if} + + (showExplainer = v)} /> diff --git a/src/lib/components/migration/WhyWeMovedModal.svelte b/src/lib/components/migration/WhyWeMovedModal.svelte new file mode 100644 index 00000000..44c225e5 --- /dev/null +++ b/src/lib/components/migration/WhyWeMovedModal.svelte @@ -0,0 +1,73 @@ + + + + + + Why the new address? + + +
+

+ We're consolidating the Mermaid ecosystem under mermaid.ai — one home for the editor, docs, + and the commercial platform. +

+ +
+
+ +
+

Same privacy

+

+ Diagrams render client-side. No data sent to servers. +

+
+
+ +
+ +
+

Same open-source code

+

Identical codebase. MIT licensed.

+
+
+ +
+ +
+

Old links still work

+

+ All mermaid.live URLs with diagrams keep working. +

+
+
+
+
+ + + + + +
+
diff --git a/src/lib/util/migration/domainMigration.ts b/src/lib/util/migration/domainMigration.ts new file mode 100644 index 00000000..76dd22ea --- /dev/null +++ b/src/lib/util/migration/domainMigration.ts @@ -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); + } +}; diff --git a/src/lib/util/promos/NewYear2026.svelte b/src/lib/util/promos/NewYear2026.svelte index d9d5112e..e213b330 100644 --- a/src/lib/util/promos/NewYear2026.svelte +++ b/src/lib/util/promos/NewYear2026.svelte @@ -1,6 +1,6 @@ + + { + if (!v) handleStartFree(); + }}> + + + Choose your editor + + You'll never see this again + + + +
+ +
+
+ Recommended +
+ +
+

Mermaid Plus

+

Unlock AI, storage and collaboration

+ +
+ + Limited time: 10% off + +
+ + + +
    +
  • + + Visual editor +
  • +
  • + + 300 AI credits +
  • +
  • + + Unlimited diagram storage +
  • +
  • + + Limitless diagram size +
  • +
  • + + View & comment collaboration +
  • +
+
+
+ + +
+

Open Source

+

Code only, no login, always free

+ + + +
    +
  • + + Diagram stored in URL +
  • +
  • + + Code editor +
  • +
  • + + Open source +
  • +
  • + + Version history +
  • +
+
+
+
+
diff --git a/src/lib/components/migration/WelcomeBanner.svelte b/src/lib/components/migration/WelcomeBanner.svelte deleted file mode 100644 index 14875961..00000000 --- a/src/lib/components/migration/WelcomeBanner.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - -{#if visible} - -{/if} - - (showExplainer = v)} /> diff --git a/src/lib/components/migration/WhyWeMovedModal.svelte b/src/lib/components/migration/WhyWeMovedModal.svelte deleted file mode 100644 index 44c225e5..00000000 --- a/src/lib/components/migration/WhyWeMovedModal.svelte +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - Why the new address? - - -
-

- We're consolidating the Mermaid ecosystem under mermaid.ai — one home for the editor, docs, - and the commercial platform. -

- -
-
- -
-

Same privacy

-

- Diagrams render client-side. No data sent to servers. -

-
-
- -
- -
-

Same open-source code

-

Identical codebase. MIT licensed.

-
-
- -
- -
-

Old links still work

-

- All mermaid.live URLs with diagrams keep working. -

-
-
-
-
- - - - - -
-
diff --git a/src/lib/util/migration/domainMigration.ts b/src/lib/util/migration/domainMigration.ts index 76dd22ea..0a51216f 100644 --- a/src/lib/util/migration/domainMigration.ts +++ b/src/lib/util/migration/domainMigration.ts @@ -1,161 +1,16 @@ -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(); + const domain = window.location.hostname; 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 +// localStorage keys that indicate a returning user. +// Note: codeStore is excluded because it's always populated with the default state on first load. const userDataStorageKeys = [ - 'codeStore', // Current diagram code/state 'manualHistoryStore', // Manual history entries 'autoHistoryStore' // Auto history entries ]; @@ -175,59 +30,31 @@ const hasStoredUserData = (): boolean => { }; /** - * 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 + * Check if the current URL has pako data (diagram content from a shared link). */ -export const checkAndRedirectIfNeeded = (): boolean => { - // Only redirect from mermaid.live - if (!isOnMermaidLive()) return false; +const hasPakoData = (): boolean => { + const hash = window.location.hash; + return ( + hash.includes('pako:') || hash.includes('base64:') || (hash.length > 10 && hash.startsWith('#')) + ); +}; - // Don't redirect if user has bypass cookie - if (hasBypassCookie()) return false; +const editorChooserStorageKey = 'mermaid-editor-chooser-dismissed'; - // 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.) +/** + * Check if the editor chooser modal should be shown. + * Shows for new users who haven't dismissed it and aren't viewing a shared link. + */ +export const shouldShowEditorChooser = (): boolean => { + if (window.localStorage.getItem(editorChooserStorageKey) === 'true') return false; 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; + if (hasPakoData()) return false; return true; }; /** - * Handle incoming redirect from mermaid.live to mermaid.ai. - * Checks for the ?from=mermaid.live param and sets session storage. + * Dismiss the editor chooser modal permanently */ -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); - } +export const dismissEditorChooser = (): void => { + window.localStorage.setItem(editorChooserStorageKey, 'true'); }; diff --git a/src/lib/util/stats.ts b/src/lib/util/stats.ts index 665879a7..3236e100 100644 --- a/src/lib/util/stats.ts +++ b/src/lib/util/stats.ts @@ -77,29 +77,24 @@ const minutesToMilliSeconds = (minutes: number): number => { return minutes * 60_000; }; +const noDelay = 0; const defaultDelay = minutesToMilliSeconds(1); const delaysPerEvent = { - bannerClick: defaultDelay, - bannerDismissed: defaultDelay, - bannerEscapeClicked: defaultDelay, + bannerClick: noDelay, copyClipboard: defaultDelay, copyMarkdown: defaultDelay, download: defaultDelay, - explainerOpened: defaultDelay, + editorChooserOpenSource: noDelay, + editorChooserPlus: noDelay, 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; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 88ecffcb..fe474870 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,11 +1,6 @@