From a6e9df07843ee2fb09608e664a146b0d4fb62a60 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 22 Sep 2025 10:37:09 -0400 Subject: [PATCH 1/3] feat: use query params to setup docs routing --- iframe-test.html | 208 +++++++++++--------------- src/css/custom.css | 58 ++++++- src/hooks/useIframe.ts | 41 +++-- src/theme/Layout/IframeNavigation.tsx | 94 ++++++++++-- src/theme/Layout/ThemeSync.tsx | 98 ++++++------ src/theme/Layout/index.tsx | 52 ++++++- src/utils/iframeConstants.ts | 70 +++++++++ 7 files changed, 421 insertions(+), 200 deletions(-) create mode 100644 src/utils/iframeConstants.ts diff --git a/iframe-test.html b/iframe-test.html index cbeb809bd1..1757272150 100644 --- a/iframe-test.html +++ b/iframe-test.html @@ -135,6 +135,13 @@

Iframe Test Page

Waiting for theme sync... + +
@@ -167,155 +174,118 @@

Cloudflare

const themeMode = document.getElementById('theme-mode'); const applyThemeButton = document.getElementById('apply-theme'); const themeStatus = document.getElementById('theme-status'); - - // Theme sync status - let isThemeSyncReady = false; - - // Update theme status display - function updateThemeStatus(ready) { - isThemeSyncReady = ready; - if (ready) { - themeStatus.textContent = 'Theme sync ready'; - themeStatus.classList.remove('status-waiting'); - themeStatus.classList.add('status-ready'); - applyThemeButton.disabled = false; - } else { - themeStatus.textContent = 'Waiting for theme sync...'; - themeStatus.classList.add('status-waiting'); - themeStatus.classList.remove('status-ready'); - applyThemeButton.disabled = true; + const showSidebarCheckbox = document.getElementById('show-sidebar'); + + let currentPath = presetUrls.value || '/'; + let initialEntryPath = currentPath; + let currentTheme = themeMode.value; + + function updateThemeStatus(message) { + themeStatus.textContent = message; + themeStatus.classList.remove('status-waiting'); + themeStatus.classList.add('status-ready'); + } + + function ensureLeadingSlash(path) { + if (!path) { + return '/'; + } + + if (path.startsWith('http://') || path.startsWith('https://')) { + const url = new URL(path); + return url.pathname + url.search + url.hash; } + + return path.startsWith('/') ? path : '/' + path; } - - // Initialize theme status - updateThemeStatus(false); - - // Function to send theme to iframes - function sendThemeToIframes(theme) { - if (isThemeSyncReady) { - // Send to both iframes - [iframe, cloudflareIframe].forEach(frame => { - if (frame && frame.contentWindow) { - frame.contentWindow.postMessage( - { type: 'theme-update', theme: theme }, - '*' - ); - } - }); - console.log('Sent theme to iframes:', theme); - } else { - console.log('Theme sync not ready yet, cannot send theme'); + + function buildIframeUrl(baseUrl, path, options) { + const url = new URL(path, baseUrl); + url.searchParams.set('embed', '1'); + + if (options.theme) { + url.searchParams.set('theme', options.theme); + } + + if (options.entry) { + url.searchParams.set('entry', options.entry); } + + if (options.sidebar) { + url.searchParams.set('sidebar', '1'); + } + + return url.toString(); } - - // Apply theme when button is clicked + + function applyIframeConfiguration() { + const path = ensureLeadingSlash(currentPath); + const entry = ensureLeadingSlash(initialEntryPath); + const sidebar = showSidebarCheckbox.checked; + + const localUrl = buildIframeUrl('http://localhost:3000', path, { + theme: currentTheme, + entry, + sidebar, + }); + + const cloudflareUrl = buildIframeUrl('https://docs.unraid.net', path, { + theme: currentTheme, + entry, + sidebar, + }); + + iframe.src = localUrl; + cloudflareIframe.src = cloudflareUrl; + updateThemeStatus('Theme applied via query params'); + } + applyThemeButton.addEventListener('click', function() { - const selectedTheme = themeMode.value; - sendThemeToIframes(selectedTheme); - }); - - // Auto-apply theme when iframe loads - iframe.addEventListener('load', function() { - console.log('Iframe loaded, waiting for theme-ready message...'); - // Reset the theme sync status when iframe loads - updateThemeStatus(false); - - // Set a timeout for theme sync readiness - const syncTimeout = setTimeout(() => { - if (!isThemeSyncReady) { - console.warn('Theme sync ready message not received within timeout period'); - // Enable theme controls anyway after timeout - updateThemeStatus(true); - } - }, 5000); - - // Store the timeout in a property so we can clear it if needed - iframe.syncTimeoutId = syncTimeout; + currentTheme = themeMode.value; + applyIframeConfiguration(); }); - - // Listen for messages from the iframes - window.addEventListener('message', function(event) { - // Check if the message is from our iframes - if (event.source === iframe.contentWindow || event.source === cloudflareIframe.contentWindow) { - // Handle theme-ready message - if (event.data && event.data.type === 'theme-ready') { - console.log('Docusaurus is ready for theme sync'); - - // Clear any existing timeout - if (iframe.syncTimeoutId) { - clearTimeout(iframe.syncTimeoutId); - } - - // Update theme status - updateThemeStatus(true); - - // Send current theme - sendThemeToIframes(themeMode.value); - } - - // Handle theme change notifications from Docusaurus - if (event.data && event.data.type === 'theme-changed') { - const newTheme = event.data.theme; - // Update the theme dropdown to match - themeMode.value = newTheme; - console.log('Theme changed in Docusaurus to:', newTheme); - } - } - }); - - // Load URL into both iframes with their respective base URLs + loadButton.addEventListener('click', function() { const selectedUrl = presetUrls.value || customUrl.value; - if (selectedUrl) { - let path = selectedUrl; - - // Extract path from full URLs - if (selectedUrl.includes('http://localhost:3000')) { - path = selectedUrl.replace('http://localhost:3000', ''); - } else if (selectedUrl.includes('https://docs.unraid.net')) { - path = selectedUrl.replace('https://docs.unraid.net', ''); - } - - // Ensure path starts with / - if (!path.startsWith('/')) { - path = '/' + path; - } - - // Load the same path into both iframes - iframe.src = 'http://localhost:3000' + path; - cloudflareIframe.src = 'https://docs.unraid.net' + path; + if (!selectedUrl) { + return; } + + currentPath = selectedUrl; + initialEntryPath = selectedUrl; + applyIframeConfiguration(); }); - - // Handle preset URL selection + presetUrls.addEventListener('change', function() { if (presetUrls.value) { customUrl.value = ''; } }); - - // Handle custom URL input + customUrl.addEventListener('input', function() { if (customUrl.value.trim()) { presetUrls.value = ''; } }); - - // Resize iframes + + showSidebarCheckbox.addEventListener('change', function() { + applyIframeConfiguration(); + }); + resizeButton.addEventListener('click', function() { const width = widthInput.value + '%'; const height = heightInput.value + 'px'; - - // Update both iframe containers + iframeContainers.forEach(container => { container.style.width = width; }); - - // Update both iframes + [iframe, cloudflareIframe].forEach(frame => { frame.style.height = height; }); }); + + applyIframeConfiguration(); }); diff --git a/src/css/custom.css b/src/css/custom.css index fe154919ef..9f5d6778cb 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -220,25 +220,69 @@ details:target { div[data-iframe="true"] .navbar, div[data-iframe="true"] header, -div[data-iframe="true"] footer, -div[data-iframe="true"] .theme-doc-sidebar-container, -div[data-iframe="true"] .theme-doc-toc-desktop, -div[data-iframe="true"] .theme-doc-toc-mobile, +div[data-iframe="true"] footer, div[data-iframe="true"] nav.pagination-nav, div[data-iframe="true"] div[role="complementary"], div[data-iframe="true"] aside { display: none !important; } -div[data-iframe="true"] main[class^="docMainContainer_"] { +div[data-iframe="true"]:not([data-iframe-sidebar="visible"]) .theme-doc-sidebar-container, +div[data-iframe="true"]:not([data-iframe-sidebar="visible"]) .theme-doc-toc-desktop, +div[data-iframe="true"]:not([data-iframe-sidebar="visible"]) .theme-doc-toc-mobile { + display: none !important; +} + +div[data-iframe="true"]:not([data-iframe-sidebar="visible"]) main[class^="docMainContainer_"] { max-width: 100% !important; width: 100% !important; } -div[data-iframe="true"] main[class^="docMainContainer_"] > div { +div[data-iframe="true"]:not([data-iframe-sidebar="visible"]) main[class^="docMainContainer_"] > div { padding-top: 1rem !important; } +div[data-iframe="true"] .iframe-back-button { + position: fixed; + right: 1.5rem; + bottom: 1.5rem; + z-index: 1000; + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border-radius: 50%; + background-color: var(--ifm-color-primary); + color: var(--ifm-button-color); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); + font-weight: 600; + text-decoration: none; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +div[data-iframe="true"] .iframe-back-button:hover { + transform: translateY(-2px); + box-shadow: 0 14px 40px rgba(0, 0, 0, 0.25); +} + +div[data-iframe="true"] .iframe-back-button:focus-visible { + outline: 2px solid var(--ifm-color-primary-dark); + outline-offset: 4px; +} + +div[data-iframe="true"] .iframe-back-button__icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +div[data-iframe="true"] .iframe-back-button svg { + width: 1.25rem; + height: 1.25rem; + fill: currentColor; +} + /* =========================== Legacy Styles - Minimal Preservation =========================== */ @@ -265,4 +309,4 @@ navpath { color: #363535; font-size: 16px; font-weight: bold; -} \ No newline at end of file +} diff --git a/src/hooks/useIframe.ts b/src/hooks/useIframe.ts index 1bfa635e9e..6d5469a735 100644 --- a/src/hooks/useIframe.ts +++ b/src/hooks/useIframe.ts @@ -1,11 +1,11 @@ import { useState, useEffect } from 'react'; - -/** - * Determines if the current window is running inside an iframe - */ -function isInIframe(): boolean { - return typeof window !== 'undefined' && window.self !== window.top; -} +import { + IFRAME_QUERY_PARAM, + IFRAME_STORAGE_KEY, + parseBooleanFlag, + clearSessionValue, + writeSessionValue, +} from '../utils/iframeConstants'; /** * React hook that detects if the current page is displayed inside an iframe @@ -15,8 +15,31 @@ export function useIframe(): boolean { const [isInIframeState, setIsInIframeState] = useState(false); useEffect(() => { - setIsInIframeState(isInIframe()); + if (typeof window === 'undefined') { + return; + } + + const params = new URLSearchParams(window.location.search); + const queryValue = params.get(IFRAME_QUERY_PARAM); + const iframeFromQuery = parseBooleanFlag(queryValue); + + const iframeFromWindow = (() => { + try { + return window.self !== window.top; + } catch (error) { + return false; + } + })(); + + if (iframeFromQuery || iframeFromWindow) { + writeSessionValue(IFRAME_STORAGE_KEY, 'true'); + setIsInIframeState(true); + return; + } + + setIsInIframeState(false); + clearSessionValue(IFRAME_STORAGE_KEY); }, []); return isInIframeState; -} \ No newline at end of file +} diff --git a/src/theme/Layout/IframeNavigation.tsx b/src/theme/Layout/IframeNavigation.tsx index 8acd125f3d..766a86abfa 100644 --- a/src/theme/Layout/IframeNavigation.tsx +++ b/src/theme/Layout/IframeNavigation.tsx @@ -1,27 +1,97 @@ -import React, { useEffect } from "react"; +import type { ReactElement } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import Link from "@docusaurus/Link"; import { useLocation } from "@docusaurus/router"; import { useIframe } from "../../hooks/useIframe"; +import { + ENTRY_QUERY_PARAM, + ENTRY_STORAGE_KEY, + readSessionValue, + writeSessionValue, +} from "../../utils/iframeConstants"; /** - * Component that handles navigation events between iframe and parent window + * Component that handles navigation events between iframe and parent window. + * Keeps the legacy postMessage flow for hosts that still rely on it while + * providing an in-iframe fallback navigation affordance. */ -export function IframeNavigation(): JSX.Element | null { +export function IframeNavigation(): ReactElement | null { const location = useLocation(); const isInIframeState = useIframe(); + const [entryPath, setEntryPath] = useState(null); - // Handle navigation events + // Legacy navigation event propagation for hosts that still listen for it. useEffect(() => { - if (isInIframeState) { - window.parent.postMessage({ - type: 'unraid-docs-navigation', + if (!isInIframeState) { + return; + } + + window.parent.postMessage( + { + type: "unraid-docs-navigation", pathname: location.pathname, search: location.search, hash: location.hash, url: window.location.href, - }, '*'); - } + }, + "*", + ); }, [location, isInIframeState]); - // This component doesn't render anything - return null; -} \ No newline at end of file + // Determine the entry point for the iframe session. + useEffect(() => { + if (!isInIframeState || typeof window === "undefined") { + return; + } + + const params = new URLSearchParams(window.location.search); + const entryFromQuery = params.get(ENTRY_QUERY_PARAM); + + if (entryFromQuery) { + writeSessionValue(ENTRY_STORAGE_KEY, entryFromQuery); + setEntryPath(entryFromQuery); + return; + } + + const entryFromStorage = readSessionValue(ENTRY_STORAGE_KEY); + if (entryFromStorage) { + setEntryPath(entryFromStorage); + return; + } + + const fallbackEntry = `${window.location.pathname}${window.location.search}${window.location.hash}`; + writeSessionValue(ENTRY_STORAGE_KEY, fallbackEntry); + setEntryPath(fallbackEntry); + }, [isInIframeState]); + + const currentPath = useMemo(() => { + if (typeof window === "undefined") { + return null; + } + + return `${window.location.pathname}${window.location.search}${window.location.hash}`; + }, [location]); + + if (!isInIframeState || !entryPath) { + return null; + } + + if (currentPath === entryPath) { + return null; + } + + return ( + + + + ); +} diff --git a/src/theme/Layout/ThemeSync.tsx b/src/theme/Layout/ThemeSync.tsx index cd6323d6e0..bbb3f0b405 100644 --- a/src/theme/Layout/ThemeSync.tsx +++ b/src/theme/Layout/ThemeSync.tsx @@ -1,70 +1,66 @@ +import type { ReactElement } from "react"; import React, { useEffect, useState } from "react"; import { useColorMode } from "@docusaurus/theme-common"; import { useIframe } from "../../hooks/useIframe"; +import { + THEME_QUERY_PARAM, + THEME_STORAGE_KEY, + normalizeTheme, + readSessionValue, + writeSessionValue, +} from "../../utils/iframeConstants"; /** * Component that handles theme synchronization between iframe and parent window */ -export function ThemeSync(): JSX.Element | null { +export function ThemeSync(): ReactElement | null { const isInIframeState = useIframe(); - const [lastSentTheme, setLastSentTheme] = useState(null); + const [lastPersistedTheme, setLastPersistedTheme] = useState(null); const { colorMode, setColorMode } = useColorMode(); - - // Ensure theme system is ready and notify parent + + // Apply the theme coming from query params or previous iframe session state. useEffect(() => { - if (isInIframeState) { - // Wait a short time to ensure Docusaurus theme system is fully initialized - const readyTimer = setTimeout(() => { - // Send ready message to parent - window.parent.postMessage({ type: 'theme-ready' }, '*'); - }, 20); - - return () => clearTimeout(readyTimer); + if (!isInIframeState || typeof window === "undefined") { + return; + } + + const params = new URLSearchParams(window.location.search); + const themeFromQuery = normalizeTheme(params.get(THEME_QUERY_PARAM)); + const themeFromStorage = normalizeTheme(readSessionValue(THEME_STORAGE_KEY)); + const themeToApply = themeFromQuery ?? themeFromStorage; + + if (themeToApply && themeToApply !== colorMode) { + setColorMode(themeToApply); } - }, [isInIframeState]); - - // Handle theme message events from parent - useEffect(() => { - if (isInIframeState) { - const handleMessage = (event) => { - // Validate the message structure - if (event.data && typeof event.data === 'object') { - // Handle theme update message - if (event.data.type === 'theme-update') { - const { theme } = event.data; - - // Set the theme based on the message - if (theme === 'dark' || theme === 'light') { - setColorMode(theme); - } - } - } - }; - // Add event listener - window.addEventListener('message', handleMessage); - - // Clean up - return () => { - window.removeEventListener('message', handleMessage); - }; + if (themeToApply) { + if (themeToApply !== lastPersistedTheme) { + writeSessionValue(THEME_STORAGE_KEY, themeToApply); + setLastPersistedTheme(themeToApply); + } + return; } - }, [isInIframeState, setColorMode]); - // Notify parent when the theme changes + if (colorMode !== lastPersistedTheme) { + writeSessionValue(THEME_STORAGE_KEY, colorMode); + setLastPersistedTheme(colorMode); + } + }, [colorMode, isInIframeState, lastPersistedTheme, setColorMode]); + + // Persist changes that happen inside the iframe so reloads stay consistent. useEffect(() => { - if (isInIframeState && colorMode !== lastSentTheme) { - // Send theme change notification to parent - window.parent.postMessage({ - type: 'theme-changed', - theme: colorMode - }, '*'); - - // Update the last sent theme - setLastSentTheme(colorMode); + if (!isInIframeState) { + return; } - }, [colorMode, isInIframeState, lastSentTheme]); + + if (colorMode === lastPersistedTheme) { + return; + } + + writeSessionValue(THEME_STORAGE_KEY, colorMode); + setLastPersistedTheme(colorMode); + }, [colorMode, isInIframeState, lastPersistedTheme]); // This component doesn't render anything return null; -} \ No newline at end of file +} diff --git a/src/theme/Layout/index.tsx b/src/theme/Layout/index.tsx index b7803cee06..7b2befca91 100644 --- a/src/theme/Layout/index.tsx +++ b/src/theme/Layout/index.tsx @@ -1,12 +1,60 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import Layout from "@theme-original/Layout"; +import { useLocation } from "@docusaurus/router"; import { ThemeSync } from "./ThemeSync"; import { IframeNavigation } from "./IframeNavigation"; import { useIframe } from "../../hooks/useIframe"; +import { + SIDEBAR_QUERY_PARAM, + SIDEBAR_STORAGE_KEY, + clearSessionValue, + parseBooleanFlag, + readSessionValue, + writeSessionValue, +} from "../../utils/iframeConstants"; export default function LayoutWrapper(props) { const isInIframeState = useIframe(); - const dataAttributes = isInIframeState ? { 'data-iframe': 'true' } : {}; + const location = useLocation(); + const [isSidebarVisible, setIsSidebarVisible] = useState(false); + + useEffect(() => { + if (!isInIframeState || typeof window === "undefined") { + setIsSidebarVisible(false); + clearSessionValue(SIDEBAR_STORAGE_KEY); + return; + } + + const params = new URLSearchParams(location?.search || window.location.search); + const hasSidebarParam = params.has(SIDEBAR_QUERY_PARAM); + const sidebarFromQuery = parseBooleanFlag(params.get(SIDEBAR_QUERY_PARAM)); + + if (hasSidebarParam) { + if (sidebarFromQuery) { + setIsSidebarVisible(true); + writeSessionValue(SIDEBAR_STORAGE_KEY, "true"); + } else { + setIsSidebarVisible(false); + clearSessionValue(SIDEBAR_STORAGE_KEY); + } + return; + } + + const sidebarFromStorage = parseBooleanFlag(readSessionValue(SIDEBAR_STORAGE_KEY)); + if (sidebarFromStorage) { + setIsSidebarVisible(true); + return; + } + + setIsSidebarVisible(false); + }, [isInIframeState, location]); + + const dataAttributes = isInIframeState + ? { + 'data-iframe': 'true', + 'data-iframe-sidebar': isSidebarVisible ? 'visible' : 'hidden', + } + : {}; return (
diff --git a/src/utils/iframeConstants.ts b/src/utils/iframeConstants.ts new file mode 100644 index 0000000000..944368429a --- /dev/null +++ b/src/utils/iframeConstants.ts @@ -0,0 +1,70 @@ +export const IFRAME_QUERY_PARAM = "embed"; +export const THEME_QUERY_PARAM = "theme"; +export const ENTRY_QUERY_PARAM = "entry"; +export const SIDEBAR_QUERY_PARAM = "sidebar"; + +export const IFRAME_STORAGE_KEY = "unraidDocsIframe"; +export const THEME_STORAGE_KEY = "unraidDocsTheme"; +export const ENTRY_STORAGE_KEY = "unraidDocsIframeEntry"; +export const SIDEBAR_STORAGE_KEY = "unraidDocsIframeSidebar"; + +const BOOLEAN_TRUE_VALUES = new Set(["1", "true", "yes"]); + +export type SupportedTheme = "light" | "dark"; + +export function parseBooleanFlag(value: string | null): boolean { + if (!value) { + return false; + } + + return BOOLEAN_TRUE_VALUES.has(value.toLowerCase()); +} + +export function normalizeTheme(theme: string | null): SupportedTheme | null { + if (!theme) { + return null; + } + + const lowerCase = theme.toLowerCase(); + if (lowerCase === "light" || lowerCase === "dark") { + return lowerCase; + } + + return null; +} + +export function readSessionValue(key: string): string | null { + if (typeof window === "undefined" || !window.sessionStorage) { + return null; + } + + try { + return window.sessionStorage.getItem(key); + } catch (error) { + return null; + } +} + +export function writeSessionValue(key: string, value: string): void { + if (typeof window === "undefined" || !window.sessionStorage) { + return; + } + + try { + window.sessionStorage.setItem(key, value); + } catch (error) { + // Failing to write session state should not break the docs experience. + } +} + +export function clearSessionValue(key: string): void { + if (typeof window === "undefined" || !window.sessionStorage) { + return; + } + + try { + window.sessionStorage.removeItem(key); + } catch (error) { + // Removing session state is a best-effort operation. + } +} From 807a033f5140628b984740910020aeb51851ba99 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 22 Sep 2025 10:39:56 -0400 Subject: [PATCH 2/3] feat: add embedding documentation and normalize path utility - Introduced a new documentation file for embedding Unraid Docs in iframe-driven experiences, detailing required and optional query parameters, session storage keys, and example URL builders. - Implemented a `normalizePath` utility function to ensure consistent handling of paths in iframe navigation. --- docs/embedding.md | 56 +++++++++++++++++++++++++++ src/theme/Layout/IframeNavigation.tsx | 22 +++++++---- src/utils/iframeConstants.ts | 18 +++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 docs/embedding.md diff --git a/docs/embedding.md b/docs/embedding.md new file mode 100644 index 0000000000..056d2ab41b --- /dev/null +++ b/docs/embedding.md @@ -0,0 +1,56 @@ +# Embedding Unraid Docs + +Use the following guidance when loading the Unraid documentation inside an iframe-driven experience. The options described here help control UI chrome, theme, and navigation behavior without relying on `postMessage` exchanges. + +## Required Query Parameters + +- `embed=1` — Opts the page into iframe-specific presentation tweaks (hides the global navbar, footer, etc.). Include this on every embedded URL. + +## Optional Query Parameters + +- `theme=` — Forces the initial Docs theme. The value is persisted for the iframe session so reloads stay consistent. +- `entry=` — Marks the logical entry point for the iframe session. Supply an absolute docs path (e.g. `/unraid-os/...`) or a full docs URL; the embedded UI shows a floating back icon that returns visitors to this path and hides itself while you remain on it. Defaults to the first loaded URL if omitted. +- `sidebar=1` — Re-enables the documentation sidebar and table of contents, which are hidden by default in embedded mode. + +## Session Storage Keys + +The iframe experience uses `window.sessionStorage` to remember state while a browser tab stays open. Host applications normally do not need to interact with these keys, but they are listed here for completeness. + +| Key | Purpose | +| --- | --- | +| `unraidDocsIframe` | Tracks whether the current session originated inside an iframe. | +| `unraidDocsTheme` | Stores the last used Docs theme so reloads stay consistent. | +| `unraidDocsIframeEntry` | Holds the iframe entry path for the fallback back button. | +| `unraidDocsIframeSidebar` | Marks whether the sidebar was explicitly enabled. | + +A host can clear these keys to reset the embedded state before opening a new iframe session. + +## Example URL Builders + +```js +function buildDocsUrl(path, { theme, entry, sidebar } = {}) { + const url = new URL(path, "https://docs.unraid.net"); + url.searchParams.set("embed", "1"); + + if (theme === "light" || theme === "dark") { + url.searchParams.set("theme", theme); + } + + if (entry) { + url.searchParams.set("entry", entry); + } + + if (sidebar) { + url.searchParams.set("sidebar", "1"); + } + + return url.toString(); +} +``` + +## Recommended Host Flow + +1. Decide which route should serve as the iframe entry point and supply it via `entry` when loading the iframe. +2. Pass the current host theme if you want the Docs theme to match immediately. +3. Toggle `sidebar=1` only when the host layout can accommodate the wider viewport required for the sidebar. +4. When tearing down an iframe session, optionally clear the session-storage keys to remove residual state before launching a new session in the same tab. diff --git a/src/theme/Layout/IframeNavigation.tsx b/src/theme/Layout/IframeNavigation.tsx index 766a86abfa..429b7e3a18 100644 --- a/src/theme/Layout/IframeNavigation.tsx +++ b/src/theme/Layout/IframeNavigation.tsx @@ -6,6 +6,7 @@ import { useIframe } from "../../hooks/useIframe"; import { ENTRY_QUERY_PARAM, ENTRY_STORAGE_KEY, + normalizePath, readSessionValue, writeSessionValue, } from "../../utils/iframeConstants"; @@ -46,22 +47,29 @@ export function IframeNavigation(): ReactElement | null { const params = new URLSearchParams(window.location.search); const entryFromQuery = params.get(ENTRY_QUERY_PARAM); + const normalizedEntryFromQuery = normalizePath(entryFromQuery); - if (entryFromQuery) { - writeSessionValue(ENTRY_STORAGE_KEY, entryFromQuery); - setEntryPath(entryFromQuery); + if (entryFromQuery && normalizedEntryFromQuery) { + writeSessionValue(ENTRY_STORAGE_KEY, normalizedEntryFromQuery); + setEntryPath(normalizedEntryFromQuery); return; } - const entryFromStorage = readSessionValue(ENTRY_STORAGE_KEY); + const entryFromStorage = normalizePath(readSessionValue(ENTRY_STORAGE_KEY)); if (entryFromStorage) { + writeSessionValue(ENTRY_STORAGE_KEY, entryFromStorage); setEntryPath(entryFromStorage); return; } - const fallbackEntry = `${window.location.pathname}${window.location.search}${window.location.hash}`; - writeSessionValue(ENTRY_STORAGE_KEY, fallbackEntry); - setEntryPath(fallbackEntry); + const fallbackEntry = normalizePath( + `${window.location.pathname}${window.location.search}${window.location.hash}`, + ); + + if (fallbackEntry) { + writeSessionValue(ENTRY_STORAGE_KEY, fallbackEntry); + setEntryPath(fallbackEntry); + } }, [isInIframeState]); const currentPath = useMemo(() => { diff --git a/src/utils/iframeConstants.ts b/src/utils/iframeConstants.ts index 944368429a..665729271d 100644 --- a/src/utils/iframeConstants.ts +++ b/src/utils/iframeConstants.ts @@ -33,6 +33,24 @@ export function normalizeTheme(theme: string | null): SupportedTheme | null { return null; } +export function normalizePath(path: string | null): string | null { + if (!path) { + return null; + } + + const trimmed = path.trim(); + if (!trimmed) { + return null; + } + + try { + const parsed = new URL(trimmed, "https://docs.unraid.net"); + return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/"; + } catch (error) { + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + } +} + export function readSessionValue(key: string): string | null { if (typeof window === "undefined" || !window.sessionStorage) { return null; From 258823abb9fd897be1d915084ccd3ed0cf3dc89a Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 22 Sep 2025 12:19:08 -0400 Subject: [PATCH 3/3] feat: implement messaging API for iframe theme synchronization - Added a new messaging API for embedding Unraid Docs in iframes, allowing dynamic theme updates and navigation events. - Introduced structured message types for better communication between the iframe and parent window. - Updated documentation to reflect the new messaging capabilities and legacy compatibility. - Enhanced existing components to utilize the new messaging system for theme synchronization and navigation handling. --- docs/embedding.md | 53 +++++- docs/unraid-os/release-notes/7.2.0.md | 28 ++-- iframe-test.html | 66 +++++++- src/theme/Layout/IframeNavigation.tsx | 17 +- src/theme/Layout/ThemeSync.tsx | 64 +++++++- src/utils/__tests__/embedMessaging.test.ts | 180 +++++++++++++++++++++ src/utils/embedMessaging.ts | 171 ++++++++++++++++++++ 7 files changed, 557 insertions(+), 22 deletions(-) create mode 100644 src/utils/__tests__/embedMessaging.test.ts create mode 100644 src/utils/embedMessaging.ts diff --git a/docs/embedding.md b/docs/embedding.md index 056d2ab41b..7d3cdd2972 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -1,6 +1,6 @@ # Embedding Unraid Docs -Use the following guidance when loading the Unraid documentation inside an iframe-driven experience. The options described here help control UI chrome, theme, and navigation behavior without relying on `postMessage` exchanges. +Use the following guidance when loading the Unraid documentation inside an iframe-driven experience. Query parameters cover the most common configuration options, and an optional `postMessage` API is available for hosts that need dynamic coordination. ## Required Query Parameters @@ -54,3 +54,54 @@ function buildDocsUrl(path, { theme, entry, sidebar } = {}) { 2. Pass the current host theme if you want the Docs theme to match immediately. 3. Toggle `sidebar=1` only when the host layout can accommodate the wider viewport required for the sidebar. 4. When tearing down an iframe session, optionally clear the session-storage keys to remove residual state before launching a new session in the same tab. + +## Messaging API + +The embedded docs surface a lightweight `postMessage` API that reports readiness, navigation, and theme changes using structured message types. All messages share the shape `{ source: "unraid-docs", type: string, ...payload }` so hosts can quickly filter for docs-specific traffic. + +### Messages emitted from the iframe + +| Type | Payload | Purpose | +| --- | --- | --- | +| `unraid-docs:ready` | `{ theme: "light" \| "dark" }` | Fired once the iframe has applied its starting theme. | +| `unraid-docs:theme-change` | `{ theme: "light" \| "dark" }` | Fired whenever the iframe theme changes (including the initial emission). | +| `unraid-docs:navigation` | `{ pathname, search, hash, url }` | Fired whenever in-iframe navigation occurs. | + +### Commands accepted by the iframe + +| Type | Payload | Purpose | +| --- | --- | --- | +| `unraid-docs:set-theme` | `{ theme: "light" \| "dark" }` | Requests a theme change without requiring a reload. | + +Example host handler: + +```js +window.addEventListener('message', (event) => { + const data = event.data; + if (!data || data.source !== 'unraid-docs') { + return; + } + + if (data.type === 'unraid-docs:theme-change') { + console.log('Docs theme changed to', data.theme); + } +}); + +function setIframeTheme(frame, theme) { + if (!frame.contentWindow) { + return; + } + + frame.contentWindow.postMessage({ + source: 'unraid-docs', + type: 'unraid-docs:set-theme', + theme, + }, '*'); +} +``` + +Refer to `iframe-test.html` for a working example that exercises both outgoing and incoming messages. + +### Legacy compatibility + +For backwards compatibility the iframe still listens for `{ type: "theme-update", theme }` and continues to emit the historical `theme-ready` and `theme-changed` messages alongside the new message types. Hosts should migrate to the structured `unraid-docs:*` contract because the legacy events will be removed in a future release. The example test page also demonstrates how to broadcast both message formats during the transition period. diff --git a/docs/unraid-os/release-notes/7.2.0.md b/docs/unraid-os/release-notes/7.2.0.md index 8db2423f4c..b2c16c5ce6 100644 --- a/docs/unraid-os/release-notes/7.2.0.md +++ b/docs/unraid-os/release-notes/7.2.0.md @@ -176,19 +176,19 @@ Login to the Unraid webGUI using Single Sign‑On (SSO) with your Unraid.net acc ### Linux kernel - version 6.12.47-Unraid \[-beta.3] - - built-in: CONFIG_EFIVAR_FS: EFI Variable filesystem - - CONFIG_INTEL_RAPL: Intel RAPL support via MSR interface + - built-in: CONFIG\_EFIVAR\_FS: EFI Variable filesystem + - CONFIG\_INTEL\_RAPL: Intel RAPL support via MSR interface - Added eMMC support: \[-beta.3] - - CONFIG_MMC: MMC/SD/SDIO card support - - CONFIG_MMC_BLOCK: MMC block device driver - - CONFIG_MMC_SDHCI: Secure Digital Host Controller Interface support - - CONFIG_MMC_SDHCI_PCI: SDHCI support on PCI bus - - CONFIG_MMC_SDHCI_ACPI: SDHCI support for ACPI enumerated SDHCI controllers - - CONFIG_MMC_SDHCI_PLTFM: SDHCI platform and OF driver helper + - CONFIG\_MMC: MMC/SD/SDIO card support + - CONFIG\_MMC\_BLOCK: MMC block device driver + - CONFIG\_MMC\_SDHCI: Secure Digital Host Controller Interface support + - CONFIG\_MMC\_SDHCI\_PCI: SDHCI support on PCI bus + - CONFIG\_MMC\_SDHCI\_ACPI: SDHCI support for ACPI enumerated SDHCI controllers + - CONFIG\_MMC\_SDHCI\_PLTFM: SDHCI platform and OF driver helper ### Base distro updates -- aaa_glibc-solibs: version 2.42 +- aaa\_glibc-solibs: version 2.42 - adwaita-icon-theme: version 48.1 - at-spi2-core: version 2.56.4 - bash: version 5.3.003 @@ -218,7 +218,7 @@ Login to the Unraid webGUI using Single Sign‑On (SSO) with your Unraid.net acc - iputils: version 20250605 - iw: version 6.17 - kbd: version 2.8.0 -- kernel-firmware: version 20250909_4573c02 +- kernel-firmware: version 20250909\_4573c02 - krb5: version 1.22 - less: version 679 - libXfixes: version 6.0.2 @@ -245,7 +245,7 @@ Login to the Unraid webGUI using Single Sign‑On (SSO) with your Unraid.net acc - mcelog: version 206 - mesa: version 25.2.2 - nano: version 8.6 -- ncurses: version 6.5_20250816 +- ncurses: version 6.5\_20250816 - nettle: version 3.10.2 - nghttp2: version 1.67.0 - nghttp3: version 1.11.0 @@ -259,9 +259,9 @@ Login to the Unraid webGUI using Single Sign‑On (SSO) with your Unraid.net acc - pango: version 1.56.4 - pciutils: version 3.14.0 - perl: version 5.42.0 -- php: version 8.3.21-x86_64-1_LT with gettext extension +- php: version 8.3.21-x86\_64-1\_LT with gettext extension - pixman: version 0.46.4 -- rclone: version 1.70.1-x86_64-1_SBo_LT.tgz +- rclone: version 1.70.1-x86\_64-1\_SBo\_LT.tgz - readline: version 8.3.001 - samba: version 4.22.2 - shadow: version 4.18.0 @@ -284,4 +284,4 @@ Login to the Unraid webGUI using Single Sign‑On (SSO) with your Unraid.net acc - xkeyboard-config: version 2.45 - xorg-server: version 21.1.18 - xterm: version 402 -- zfs: version zfs-2.3.4_6.12.47_Unraid-x86_64-2_LT +- zfs: version zfs-2.3.4\_6.12.47\_Unraid-x86\_64-2\_LT diff --git a/iframe-test.html b/iframe-test.html index 1757272150..cf1b090909 100644 --- a/iframe-test.html +++ b/iframe-test.html @@ -176,6 +176,11 @@

Cloudflare

const themeStatus = document.getElementById('theme-status'); const showSidebarCheckbox = document.getElementById('show-sidebar'); + const EMBED_MESSAGE_SOURCE = 'unraid-docs'; + const EMBED_READY = 'unraid-docs:ready'; + const EMBED_THEME_CHANGE = 'unraid-docs:theme-change'; + const EMBED_SET_THEME = 'unraid-docs:set-theme'; + let currentPath = presetUrls.value || '/'; let initialEntryPath = currentPath; let currentTheme = themeMode.value; @@ -186,6 +191,12 @@

Cloudflare

themeStatus.classList.add('status-ready'); } + function markThemeWaiting(message) { + themeStatus.textContent = message; + themeStatus.classList.remove('status-ready'); + themeStatus.classList.add('status-waiting'); + } + function ensureLeadingSlash(path) { if (!path) { return '/'; @@ -237,11 +248,64 @@

Cloudflare

iframe.src = localUrl; cloudflareIframe.src = cloudflareUrl; - updateThemeStatus('Theme applied via query params'); + markThemeWaiting('Awaiting iframe theme readiness...'); } + function broadcastThemeUpdate(theme) { + [iframe, cloudflareIframe].forEach(frame => { + if (!frame.contentWindow) { + return; + } + + frame.contentWindow.postMessage( + { + source: EMBED_MESSAGE_SOURCE, + type: EMBED_SET_THEME, + theme, + }, + '*' + ); + + frame.contentWindow.postMessage( + { + type: 'theme-update', + theme, + }, + '*' + ); + }); + } + + window.addEventListener('message', function(event) { + const data = event.data; + if (!data || typeof data !== 'object') { + return; + } + + if (data.source === EMBED_MESSAGE_SOURCE) { + if (data.type === EMBED_READY) { + updateThemeStatus(`Iframe reported ready with ${data.theme} theme`); + return; + } + + if (data.type === EMBED_THEME_CHANGE) { + updateThemeStatus(`Iframe switched to ${data.theme} theme`); + return; + } + } + + if (data.type === 'theme-ready') { + updateThemeStatus(`Legacy theme-ready received (${data.theme})`); + } + + if (data.type === 'theme-changed') { + updateThemeStatus(`Legacy theme-changed received (${data.theme})`); + } + }); + applyThemeButton.addEventListener('click', function() { currentTheme = themeMode.value; + broadcastThemeUpdate(currentTheme); applyIframeConfiguration(); }); diff --git a/src/theme/Layout/IframeNavigation.tsx b/src/theme/Layout/IframeNavigation.tsx index 429b7e3a18..2a0a7446d9 100644 --- a/src/theme/Layout/IframeNavigation.tsx +++ b/src/theme/Layout/IframeNavigation.tsx @@ -10,6 +10,7 @@ import { readSessionValue, writeSessionValue, } from "../../utils/iframeConstants"; +import { createNavigationMessage, postEmbedMessage } from "../../utils/embedMessaging"; /** * Component that handles navigation events between iframe and parent window. @@ -23,17 +24,23 @@ export function IframeNavigation(): ReactElement | null { // Legacy navigation event propagation for hosts that still listen for it. useEffect(() => { - if (!isInIframeState) { + if (!isInIframeState || typeof window === "undefined") { return; } + const payload = { + pathname: location.pathname, + search: location.search, + hash: location.hash, + url: window.location.href, + }; + + postEmbedMessage(createNavigationMessage(payload)); + window.parent.postMessage( { type: "unraid-docs-navigation", - pathname: location.pathname, - search: location.search, - hash: location.hash, - url: window.location.href, + ...payload, }, "*", ); diff --git a/src/theme/Layout/ThemeSync.tsx b/src/theme/Layout/ThemeSync.tsx index bbb3f0b405..ec440e7a00 100644 --- a/src/theme/Layout/ThemeSync.tsx +++ b/src/theme/Layout/ThemeSync.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from "react"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useColorMode } from "@docusaurus/theme-common"; import { useIframe } from "../../hooks/useIframe"; import { @@ -9,6 +9,15 @@ import { readSessionValue, writeSessionValue, } from "../../utils/iframeConstants"; +import { + EMBED_MESSAGE_TYPES, + createReadyMessage, + createThemeChangeMessage, + postEmbedMessage, + sendLegacyThemeMessage, + subscribeToEmbedMessages, +} from "../../utils/embedMessaging"; +import type { SupportedTheme } from "../../utils/iframeConstants"; /** * Component that handles theme synchronization between iframe and parent window @@ -17,6 +26,13 @@ export function ThemeSync(): ReactElement | null { const isInIframeState = useIframe(); const [lastPersistedTheme, setLastPersistedTheme] = useState(null); const { colorMode, setColorMode } = useColorMode(); + const readySentRef = useRef(false); + const lastAnnouncedThemeRef = useRef(null); + const currentThemeRef = useRef(normalizeTheme(colorMode)); + + useEffect(() => { + currentThemeRef.current = normalizeTheme(colorMode); + }, [colorMode]); // Apply the theme coming from query params or previous iframe session state. useEffect(() => { @@ -61,6 +77,52 @@ export function ThemeSync(): ReactElement | null { setLastPersistedTheme(colorMode); }, [colorMode, isInIframeState, lastPersistedTheme]); + useEffect(() => { + if (!isInIframeState) { + return; + } + + const unsubscribe = subscribeToEmbedMessages((message) => { + if (message.type !== EMBED_MESSAGE_TYPES.SET_THEME) { + return; + } + + const incomingTheme = message.theme; + if (!incomingTheme || incomingTheme === currentThemeRef.current) { + return; + } + + setColorMode(incomingTheme); + }); + + return unsubscribe; + }, [isInIframeState, setColorMode]); + + useEffect(() => { + if (!isInIframeState) { + return; + } + + const normalizedTheme = normalizeTheme(colorMode); + if (!normalizedTheme) { + return; + } + + if (!readySentRef.current) { + postEmbedMessage(createReadyMessage(normalizedTheme)); + sendLegacyThemeMessage("theme-ready", normalizedTheme); + readySentRef.current = true; + } + + if (lastAnnouncedThemeRef.current === normalizedTheme) { + return; + } + + postEmbedMessage(createThemeChangeMessage(normalizedTheme)); + sendLegacyThemeMessage("theme-changed", normalizedTheme); + lastAnnouncedThemeRef.current = normalizedTheme; + }, [colorMode, isInIframeState]); + // This component doesn't render anything return null; } diff --git a/src/utils/__tests__/embedMessaging.test.ts b/src/utils/__tests__/embedMessaging.test.ts new file mode 100644 index 0000000000..167bfcf89a --- /dev/null +++ b/src/utils/__tests__/embedMessaging.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { + EMBED_MESSAGE_TYPES, + createReadyMessage, + postEmbedMessage, + sendLegacyThemeMessage, + subscribeToEmbedMessages, +} from "../embedMessaging"; + +const originalWindow = globalThis.window; + +type Listener = (event: MessageEvent) => void; + +type StubWindow = Window & { + __listeners: Map>; + parent: { postMessage: ReturnType } | Window; +}; + +function setStubWindow({ inIframe }: { inIframe: boolean }): { window: StubWindow; parentPostMessage: ReturnType } { + const parentPostMessage = vi.fn(); + const listeners = new Map>(); + + const stub: Partial = { + __listeners: listeners, + addEventListener: (type: string, listener: EventListener): void => { + const typedListener = listener as Listener; + const collection = listeners.get(type) ?? new Set(); + collection.add(typedListener); + listeners.set(type, collection); + }, + removeEventListener: (type: string, listener: EventListener): void => { + const typedListener = listener as Listener; + const collection = listeners.get(type); + if (!collection) { + return; + } + collection.delete(typedListener); + if (collection.size === 0) { + listeners.delete(type); + } + }, + postMessage: vi.fn(), + }; + + if (inIframe) { + stub.parent = { + postMessage: parentPostMessage, + } as StubWindow["parent"]; + } else { + // When not inside an iframe, window.parent === window. + // We assign a placeholder and reconcile after we cast to Window. + stub.parent = stub as unknown as Window; + } + + const castStub = stub as StubWindow; + if (!inIframe) { + castStub.parent = castStub as unknown as Window; + } + + Object.defineProperty(globalThis, "window", { + configurable: true, + value: castStub, + }); + + return { window: castStub, parentPostMessage }; +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +afterEach(() => { + if (originalWindow) { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + }); + } else { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (globalThis as unknown as { window?: Window }).window; + } +}); + +describe("postEmbedMessage", () => { + it("posts to the parent window when inside an iframe", () => { + const { parentPostMessage } = setStubWindow({ inIframe: true }); + + postEmbedMessage(createReadyMessage("dark")); + + expect(parentPostMessage).toHaveBeenCalledWith( + { + source: "unraid-docs", + type: EMBED_MESSAGE_TYPES.READY, + theme: "dark", + }, + "*", + ); + }); + + it("no-ops when the docs are not embedded", () => { + const { parentPostMessage } = setStubWindow({ inIframe: false }); + + postEmbedMessage(createReadyMessage("light")); + + expect(parentPostMessage).not.toHaveBeenCalled(); + }); +}); + +describe("subscribeToEmbedMessages", () => { + it("invokes the handler for new set-theme messages", () => { + const { window } = setStubWindow({ inIframe: true }); + const handler = vi.fn(); + + const unsubscribe = subscribeToEmbedMessages(handler); + + const listeners = window.__listeners.get("message"); + expect(listeners).toBeTruthy(); + + const message = { + data: { + source: "unraid-docs", + type: EMBED_MESSAGE_TYPES.SET_THEME, + theme: "dark", + }, + } as MessageEvent; + + listeners?.forEach((listener) => listener(message)); + + expect(handler).toHaveBeenCalledWith({ + source: "unraid-docs", + type: EMBED_MESSAGE_TYPES.SET_THEME, + theme: "dark", + legacy: undefined, + }); + + unsubscribe(); + }); + + it("maps legacy theme-update messages into the new format", () => { + const { window } = setStubWindow({ inIframe: true }); + const handler = vi.fn(); + + subscribeToEmbedMessages(handler); + + const listeners = window.__listeners.get("message"); + + const legacyEvent = { + data: { + type: "theme-update", + theme: "LIGHT", + }, + } as MessageEvent; + + listeners?.forEach((listener) => listener(legacyEvent)); + + expect(handler).toHaveBeenCalledWith({ + source: "unraid-docs", + type: EMBED_MESSAGE_TYPES.SET_THEME, + theme: "light", + legacy: true, + }); + }); +}); + +describe("sendLegacyThemeMessage", () => { + it("re-emits the historical message types", () => { + const { parentPostMessage } = setStubWindow({ inIframe: true }); + + sendLegacyThemeMessage("theme-changed", "dark"); + + expect(parentPostMessage).toHaveBeenCalledWith( + { + type: "theme-changed", + theme: "dark", + }, + "*", + ); + }); +}); diff --git a/src/utils/embedMessaging.ts b/src/utils/embedMessaging.ts new file mode 100644 index 0000000000..d9a2585533 --- /dev/null +++ b/src/utils/embedMessaging.ts @@ -0,0 +1,171 @@ +import { normalizeTheme, type SupportedTheme } from "./iframeConstants"; + +const EMBED_MESSAGE_SOURCE = "unraid-docs" as const; + +export const EMBED_MESSAGE_TYPES = { + READY: "unraid-docs:ready", + THEME_CHANGE: "unraid-docs:theme-change", + NAVIGATION: "unraid-docs:navigation", + SET_THEME: "unraid-docs:set-theme", +} as const; + +export type EmbedReadyMessage = { + source: typeof EMBED_MESSAGE_SOURCE; + type: typeof EMBED_MESSAGE_TYPES.READY; + theme: SupportedTheme; +}; + +export type EmbedThemeChangeMessage = { + source: typeof EMBED_MESSAGE_SOURCE; + type: typeof EMBED_MESSAGE_TYPES.THEME_CHANGE; + theme: SupportedTheme; +}; + +export type EmbedNavigationMessage = { + source: typeof EMBED_MESSAGE_SOURCE; + type: typeof EMBED_MESSAGE_TYPES.NAVIGATION; + pathname: string; + search: string; + hash: string; + url: string; +}; + +export type EmbedOutboundMessage = + | EmbedReadyMessage + | EmbedThemeChangeMessage + | EmbedNavigationMessage; + +export type EmbedSetThemeMessage = { + source: typeof EMBED_MESSAGE_SOURCE; + type: typeof EMBED_MESSAGE_TYPES.SET_THEME; + theme: SupportedTheme; + /** Indicates the message came from the legacy postMessage contract. */ + legacy?: boolean; +}; + +export type EmbedInboundMessage = EmbedSetThemeMessage; + +export type LegacyThemeMessageType = "theme-ready" | "theme-changed"; + +export type LegacyThemeUpdateMessage = { + type: "theme-update"; + theme: string; +}; + +function isWindowAvailable(): boolean { + return typeof window !== "undefined"; +} + +function isInIframe(): boolean { + if (!isWindowAvailable()) { + return false; + } + + try { + return window.parent !== window; + } catch (error) { + return false; + } +} + +function isEmbedSetThemeMessage(data: unknown): data is EmbedSetThemeMessage { + if (!data || typeof data !== "object") { + return false; + } + + const candidate = data as Partial; + if ( + candidate.source !== EMBED_MESSAGE_SOURCE || + candidate.type !== EMBED_MESSAGE_TYPES.SET_THEME || + typeof candidate.theme !== "string" + ) { + return false; + } + + return normalizeTheme(candidate.theme) !== null; +} + +function isLegacyThemeUpdateMessage(data: unknown): data is LegacyThemeUpdateMessage { + if (!data || typeof data !== "object") { + return false; + } + + const candidate = data as Partial; + return candidate.type === "theme-update" && typeof candidate.theme === "string"; +} + +export function postEmbedMessage(message: EmbedOutboundMessage): void { + if (!isInIframe()) { + return; + } + + window.parent.postMessage(message, "*"); +} + +export function subscribeToEmbedMessages(handler: (message: EmbedInboundMessage) => void): () => void { + if (!isWindowAvailable()) { + return () => undefined; + } + + const listener = (event: MessageEvent): void => { + if (isEmbedSetThemeMessage(event.data)) { + handler({ + ...event.data, + theme: normalizeTheme(event.data.theme)!, + }); + return; + } + + if (isLegacyThemeUpdateMessage(event.data)) { + const normalized = normalizeTheme(event.data.theme); + if (!normalized) { + return; + } + + handler({ + source: EMBED_MESSAGE_SOURCE, + type: EMBED_MESSAGE_TYPES.SET_THEME, + theme: normalized, + legacy: true, + }); + } + }; + + window.addEventListener("message", listener); + + return () => { + window.removeEventListener("message", listener); + }; +} + +export function sendLegacyThemeMessage(type: LegacyThemeMessageType, theme: SupportedTheme): void { + if (!isInIframe()) { + return; + } + + window.parent.postMessage({ type, theme }, "*"); +} + +export function createReadyMessage(theme: SupportedTheme): EmbedReadyMessage { + return { + source: EMBED_MESSAGE_SOURCE, + type: EMBED_MESSAGE_TYPES.READY, + theme, + }; +} + +export function createThemeChangeMessage(theme: SupportedTheme): EmbedThemeChangeMessage { + return { + source: EMBED_MESSAGE_SOURCE, + type: EMBED_MESSAGE_TYPES.THEME_CHANGE, + theme, + }; +} + +export function createNavigationMessage(payload: Omit): EmbedNavigationMessage { + return { + source: EMBED_MESSAGE_SOURCE, + type: EMBED_MESSAGE_TYPES.NAVIGATION, + ...payload, + }; +}