From 5dd431023dd457cb4fa5303d9e4af2314c7c7382 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Mon, 27 Apr 2026 14:17:23 +0200 Subject: [PATCH] Auto-toggled code groups Synchronize tab selections of certain code groups across pages and persist them locally. Known values: - Node.js / Java - macOS / Linux / Windows --- .vitepress/config.js | 8 +- .vitepress/lib/restoreCodeGroupPreferences.js | 193 ++++++++++ .vitepress/lib/useCodeGroupSync.ts | 345 ++++++++++++++++++ .vitepress/theme/index.ts | 7 + 4 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 .vitepress/lib/restoreCodeGroupPreferences.js create mode 100644 .vitepress/lib/useCodeGroupSync.ts diff --git a/.vitepress/config.js b/.vitepress/config.js index 4b9b2d721d..83ff1d816e 100644 --- a/.vitepress/config.js +++ b/.vitepress/config.js @@ -3,11 +3,15 @@ const base = process.env.GH_BASE || '/docs/' // Construct vitepress config object... import path from 'node:path' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitepress' import playground from './lib/cds-playground/index.js' import languages from './languages' import { Menu } from './menu.js' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const config = defineConfig({ title: 'capire', @@ -77,7 +81,9 @@ const config = defineConfig({ ['link', { rel: 'shortcut icon', href: base+'favicon.ico' }], ['link', { rel: 'apple-touch-icon', sizes: '180x180', href: base+'logos/cap.png' }], // Inline script to restore impl-variant selection immediately (before first paint) - ['script', { id: 'check-impl-variant' }, `{const p=new URLSearchParams(location.search),v=p.get('impl-variant')||localStorage.getItem('impl-variant');if(v)document.documentElement.classList.add(v)}`] + ['script', { id: 'check-impl-variant' }, `{const p=new URLSearchParams(location.search),v=p.get('impl-variant')||localStorage.getItem('impl-variant');if(v)document.documentElement.classList.add(v)}`], + // Inline script to restore code group tab preferences (before Vue hydration) + ['script', {}, readFileSync(path.resolve(__dirname, './lib/restoreCodeGroupPreferences.js'), 'utf-8')] ], vite: { diff --git a/.vitepress/lib/restoreCodeGroupPreferences.js b/.vitepress/lib/restoreCodeGroupPreferences.js new file mode 100644 index 0000000000..f3202783e8 --- /dev/null +++ b/.vitepress/lib/restoreCodeGroupPreferences.js @@ -0,0 +1,193 @@ +;(() => { + // Code Group Tab Synchronization - Early Execution Script + // This script loads preferences and applies them before Vue hydration to prevent flicker + // + // Features: + // - Syncs tabs with exact or fuzzy matching ("/" delimiter) + // - "macOS/Linux" matches "macOS/Linux", "macOS", and "Linux" + // - "macOS" matches "macOS" and "macOS/Linux" + // - Stores preferences by independent dimensions (runtime vs OS) + // - runtime: Node.js ↔ Java + // - os: macOS ↔ Windows ↔ Linux (+ combinations) + // - Storage format: { "runtime": "Java", "os": "macOS" } + // - First entry in each dimension array is the default + + // Define independent dimensions of tabs + // Tabs within a dimension are mutually exclusive + // Note: First entry in each dimension is the default (used when no preference is saved) + // Note: Combinations like "macOS/Linux" are handled automatically by fuzzy matching + const TAB_DIMENSIONS = { + 'runtime': ['Node.js', 'Java'], + 'os': ['macOS', 'Windows', 'Linux'] + } + + // Determine which dimension a tab belongs to (including fuzzy matches) + const getTabDimension = (tabLabel) => { + for (const [dimension, tabs] of Object.entries(TAB_DIMENSIONS)) { + for (const dimTab of tabs) { + if (tabsMatch(tabLabel, dimTab)) { + return dimension + } + } + } + return null // Unknown dimension + } + + // Check if two tab labels match (exact or fuzzy match) + // Treats "/" as a delimiter for combined tabs + const tabsMatch = (tab1, tab2) => { + if (tab1 === tab2) return true + + // Split by "/" to get components + const components1 = tab1.split('/').map(s => s.trim()) + const components2 = tab2.split('/').map(s => s.trim()) + + // Check if any component from tab1 exists in components2 or vice versa + return components1.some(c1 => components2.includes(c1)) || + components2.some(c2 => components1.includes(c2)) + } + + // Get active tabs from localStorage (dimension-based storage) + const getActiveTabsByDimension = () => { + try { + const stored = localStorage.getItem('code-group-active-tabs') + if (stored) { + const parsed = JSON.parse(stored) + // Handle both old format (array) and new format (object) + if (Array.isArray(parsed)) { + // Migrate from old single-value format + return {} + } + return typeof parsed === 'object' ? parsed : {} + } + } catch (e) { + // localStorage might not be available or JSON parse failed + } + return {} + } + + // Clean up old localStorage entries from previous implementation + const cleanupOldEntries = () => { + try { + const keysToRemove = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key && (key.startsWith('code-group-preference:') || key.startsWith('code-group-tab:'))) { + keysToRemove.push(key) + } + } + keysToRemove.forEach(key => localStorage.removeItem(key)) + } catch (e) { + // localStorage might not be available + } + } + + // Clean up old entries on first run + cleanupOldEntries() + + // Determine the best tab from a set based on preferences and defaults + const getBestTab = (tabs, activeTabs) => { + // Check if any tab matches an active preference (exact or fuzzy match) + for (const tab of tabs) { + // Find which dimension this tab belongs to + const dimension = getTabDimension(tab) + if (dimension && activeTabs[dimension]) { + const activeTab = activeTabs[dimension] + // Check if this tab matches the active preference + if (tab === activeTab || tabsMatch(tab, activeTab)) { + return tab + } + } + } + + // Apply dimension defaults (first entry in each dimension) + for (const tab of tabs) { + const dimension = getTabDimension(tab) + if (dimension && TAB_DIMENSIONS[dimension]) { + const defaultTab = TAB_DIMENSIONS[dimension][0] + // Check if this tab matches the dimension default (exact or fuzzy) + if (tab === defaultTab || tabsMatch(tab, defaultTab)) { + return tab + } + } + } + + // Fallback to first tab alphabetically if no match + return tabs.sort()[0] + } + + // Load active tabs from storage + const activeTabs = getActiveTabsByDimension() + + // Store in global variable for later use by Vue components + window.__CODE_GROUP_ACTIVE_TABS__ = activeTabs + + // Apply preferences to a code group element + const applyToCodeGroup = (element) => { + const tabElements = element.querySelectorAll('.tabs label') + const tabs = Array.from(tabElements).map((label) => + (label.textContent || '').trim() + ).filter(Boolean) + + if (tabs.length === 0) return + + // Determine which tab should be selected + const selectedTab = getBestTab(tabs, activeTabs) + const selectedIndex = tabs.indexOf(selectedTab) + + if (selectedIndex === -1) return + + // Apply the selection immediately to prevent flicker + const inputs = element.querySelectorAll('.tabs input') + const blocks = element.querySelectorAll('div[class*="language-"], .vp-block') + + inputs.forEach((input, index) => { + input.checked = (index === selectedIndex) + }) + + blocks.forEach((block, index) => { + if (index === selectedIndex) { + block.classList.add('active') + } else { + block.classList.remove('active') + } + }) + } + + const applyToAllCodeGroups = () => { + const codeGroups = document.querySelectorAll('.vp-code-group') + codeGroups.forEach(applyToCodeGroup) + } + + // Apply immediately to any existing code groups (runs synchronously) + applyToAllCodeGroups() + + // Watch for code groups being added dynamically (SPA navigation, HMR in dev mode) + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node instanceof HTMLElement) { + if (node.classList?.contains('vp-code-group')) { + applyToCodeGroup(node) + } else if (node.querySelector) { + const codeGroups = node.querySelectorAll('.vp-code-group') + codeGroups.forEach(applyToCodeGroup) + } + } + } + } + }) + + // Start observing as soon as script runs + if (document.documentElement) { + observer.observe(document.documentElement, { + childList: true, + subtree: true + }) + } + + // Apply again on DOMContentLoaded as safety net + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', applyToAllCodeGroups) + } +})() diff --git a/.vitepress/lib/useCodeGroupSync.ts b/.vitepress/lib/useCodeGroupSync.ts new file mode 100644 index 0000000000..7192172e3b --- /dev/null +++ b/.vitepress/lib/useCodeGroupSync.ts @@ -0,0 +1,345 @@ +/** + * Code Group Tab Synchronization Composable + * + * Manages tab preferences for VitePress code groups: + * - Synchronizes tab selection across all code groups with exact or fuzzy matching + * - Fuzzy matching treats "/" as delimiter: "macOS/Linux" matches both "macOS" and "Linux" + * - Stores preferences by independent dimensions (runtime vs OS): + * - runtime: Node.js ↔ Java + * - os: Windows ↔ macOS ↔ Linux (+ combinations) + * - Selecting "Java" won't overwrite "macOS" (different dimensions) + * - Storage format: { "runtime": "Java", "os": "macOS" } + * - First entry in each dimension array is the default + */ + +// Define independent dimensions of tabs (must match restoreCodeGroupPreferences.js) +// Tabs within a dimension are mutually exclusive +// Note: First entry in each dimension is the default (used when no preference is saved) +// Note: Combinations like "macOS/Linux" are handled automatically by fuzzy matching +const TAB_DIMENSIONS: Record = { + 'runtime': ['Node.js', 'Java'], + 'os': ['macOS', 'Windows', 'Linux'] +} + +interface CodeGroupInfo { + element: HTMLElement + tabs: string[] +} + +/** + * Determine which dimension a tab belongs to (including fuzzy matches) + */ +function getTabDimension(tabLabel: string): string | null { + for (const [dimension, tabs] of Object.entries(TAB_DIMENSIONS)) { + for (const dimTab of tabs) { + if (tabsMatch(tabLabel, dimTab)) { + return dimension + } + } + } + return null // Unknown dimension +} + +/** + * Check if two tab labels match (exact or fuzzy match) + * Treats "/" as a delimiter for combined tabs + * Examples: + * - "macOS" matches "macOS" (exact) + * - "macOS" matches "macOS/Linux" (fuzzy - macOS is part of the combined tab) + * - "macOS/Linux" matches "macOS" (fuzzy - macOS is part of the combined tab) + * - "Windows" does NOT match "macOS/Linux" (no overlap) + */ +function tabsMatch(tab1: string, tab2: string): boolean { + if (tab1 === tab2) return true + + // Split by "/" to get components + const components1 = tab1.split('/').map(s => s.trim()) + const components2 = tab2.split('/').map(s => s.trim()) + + // Check if any component from tab1 exists in components2 or vice versa + return components1.some(c1 => components2.includes(c1)) || + components2.some(c2 => components1.includes(c2)) +} + +/** + * Get the best tab to select based on preferences and defaults + */ +function getBestTab(tabs: string[]): string { + // Get active tabs from localStorage or early-loaded window variable + let activeTabs: Record = getActiveTabsByDimension() + + // Fallback to early-loaded active tabs if available + const earlyActiveTabs = (window as any).__CODE_GROUP_ACTIVE_TABS__ + if (earlyActiveTabs && Object.keys(earlyActiveTabs).length > 0) { + activeTabs = earlyActiveTabs + } + + // Check if any tab matches an active preference (exact or fuzzy match) + for (const tab of tabs) { + // Find which dimension this tab belongs to + const dimension = getTabDimension(tab) + if (dimension && activeTabs[dimension]) { + const activeTab = activeTabs[dimension] + // Check if this tab matches the active preference + if (tab === activeTab || tabsMatch(tab, activeTab)) { + return tab + } + } + } + + // Apply dimension defaults (first entry in each dimension) + for (const tab of tabs) { + const dimension = getTabDimension(tab) + if (dimension && TAB_DIMENSIONS[dimension]) { + const defaultTab = TAB_DIMENSIONS[dimension][0] + // Check if this tab matches the dimension default (exact or fuzzy) + if (tab === defaultTab || tabsMatch(tab, defaultTab)) { + return tab + } + } + } + + // Fallback to first tab alphabetically + return [...tabs].sort()[0] +} + +/** + * Get active tabs from localStorage (dimension-based storage) + */ +function getActiveTabsByDimension(): Record { + try { + const stored = localStorage.getItem('code-group-active-tabs') + if (stored) { + const parsed = JSON.parse(stored) + // Handle both old format (array) and new format (object) + if (Array.isArray(parsed)) { + // Migrate from old single-value format + return {} + } + return typeof parsed === 'object' ? parsed : {} + } + } catch (e) { + // localStorage might not be available or JSON parse failed + } + return {} +} + +/** + * Save active tabs to localStorage (dimension-based storage) + */ +function saveActiveTabsByDimension(activeTabs: Record): void { + try { + localStorage.setItem('code-group-active-tabs', JSON.stringify(activeTabs)) + } catch (e) { + // localStorage might not be available + } +} + +/** + * Add a tab to the active tabs (updates only the relevant dimension) + */ +function addActiveTab(tabLabel: string): void { + const activeTabs = getActiveTabsByDimension() + const dimension = getTabDimension(tabLabel) + + if (dimension) { + // Update only this dimension + activeTabs[dimension] = tabLabel + saveActiveTabsByDimension(activeTabs) + } +} + +/** + * Find all code groups in the document + */ +function findCodeGroups(): CodeGroupInfo[] { + const codeGroups: CodeGroupInfo[] = [] + const elements = document.querySelectorAll('.vp-code-group') + + elements.forEach((element) => { + const tabElements = element.querySelectorAll('.tabs label') + const tabs = Array.from(tabElements).map((label) => + (label.textContent || '').trim() + ).filter(Boolean) + + if (tabs.length > 0) { + codeGroups.push({ + element: element as HTMLElement, + tabs + }) + } + }) + + return codeGroups +} + +/** + * Apply saved preference to a code group + */ +function applyPreference(codeGroup: CodeGroupInfo): void { + const { element, tabs } = codeGroup + const selectedTab = getBestTab(tabs) + + // Find and check the corresponding radio button and activate content + const labels = element.querySelectorAll('.tabs label') + const blocks = element.querySelectorAll('div[class*="language-"], .vp-block') + + labels.forEach((label, index) => { + const tabLabel = (label.textContent || '').trim() + const input = element.querySelector(`.tabs input:nth-of-type(${index + 1})`) as HTMLInputElement + const block = blocks[index] as HTMLElement + + if (tabLabel === selectedTab) { + // Activate this tab + if (input && !input.checked) { + input.checked = true + } + if (block && !block.classList.contains('active')) { + block.classList.add('active') + } + } else { + // Deactivate other tabs + if (input && input.checked) { + input.checked = false + } + if (block && block.classList.contains('active')) { + block.classList.remove('active') + } + } + }) +} + +/** + * Synchronize tab selection across all code groups + * Syncs both exact tab set matches and fuzzy matches (tab label matching) + */ +function syncTabs(selectedTab: string): void { + const codeGroups = findCodeGroups() + + codeGroups.forEach((codeGroup) => { + // Find a matching tab in this code group (exact or fuzzy match) + const matchingTab = codeGroup.tabs.find(tab => tabsMatch(tab, selectedTab)) + + if (matchingTab) { + const { element, tabs } = codeGroup + const tabIndex = tabs.indexOf(matchingTab) + const blocks = element.querySelectorAll('div[class*="language-"], .vp-block') + + if (tabIndex !== -1) { + // Update all tabs and blocks in this code group + tabs.forEach((_, index) => { + const input = element.querySelector(`.tabs input:nth-of-type(${index + 1})`) as HTMLInputElement + const block = blocks[index] as HTMLElement + + if (index === tabIndex) { + // Activate selected tab + if (input) input.checked = true + if (block) block.classList.add('active') + } else { + // Deactivate other tabs + if (input) input.checked = false + if (block) block.classList.remove('active') + } + }) + } + } + }) + + // Save the selected tab to active tabs + addActiveTab(selectedTab) +} + +/** + * Setup event listeners for tab clicks + */ +function setupEventListeners(): void { + // Use event delegation for better performance + document.addEventListener('click', (event) => { + const target = event.target as HTMLElement + + // Check if clicked on a code group tab label or input + const label = target.closest('.vp-code-group .tabs label') as HTMLLabelElement + if (!label) return + + const codeGroup = target.closest('.vp-code-group') as HTMLElement + if (!codeGroup) return + + const tabLabel = (label.textContent || '').trim() + if (!tabLabel) return + + // Sync all code groups with fuzzy matching + syncTabs(tabLabel) + }) +} + +/** + * Initialize code group synchronization + */ +function initCodeGroupSync(): void { + // Apply saved preferences to all code groups + const codeGroups = findCodeGroups() + codeGroups.forEach(applyPreference) + + // Setup event listeners for future interactions + setupEventListeners() +} + +/** + * Reinitialize for SPA navigation + */ +function reinitCodeGroupSync(): void { + // Apply preferences to newly rendered code groups + const codeGroups = findCodeGroups() + codeGroups.forEach(applyPreference) +} + +/** + * Setup code group synchronization + * Call this function when the app is mounted and after route changes + */ +export function setupCodeGroupSync(): void { + // Initialize on first load + if (typeof window !== 'undefined') { + // Wait for DOM to be ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => initCodeGroupSync()) + } else { + // DOM is already ready + initCodeGroupSync() + } + + // Handle dynamic content changes (e.g., hot module replacement in dev mode) + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.addedNodes.length > 0) { + // Check if any added nodes contain code groups + for (const node of mutation.addedNodes) { + if (node instanceof HTMLElement) { + if (node.classList?.contains('vp-code-group') || + node.querySelector?.('.vp-code-group')) { + reinitCodeGroupSync() + break + } + } + } + } + } + }) + + // Start observing the document body for added code groups + observer.observe(document.body, { + childList: true, + subtree: true + }) + } +} + +/** + * Reinitialize after route change (for SPA navigation) + */ +export function onRouteChange(): void { + if (typeof window !== 'undefined') { + // Use setTimeout to ensure DOM has updated + setTimeout(() => { reinitCodeGroupSync() }, 0) + } +} diff --git a/.vitepress/theme/index.ts b/.vitepress/theme/index.ts index 18433e6ad3..1bc3806ba1 100644 --- a/.vitepress/theme/index.ts +++ b/.vitepress/theme/index.ts @@ -12,6 +12,7 @@ import Since from './components/Since.vue'; import UnderConstruction from './components/UnderConstruction.vue'; import CfgInspect from './components/ConfigInspect.vue'; import TwoslashFloatingVue from '@shikijs/vitepress-twoslash/client' +import { setupCodeGroupSync, onRouteChange } from '../lib/useCodeGroupSync' import '@shikijs/vitepress-twoslash/style.css' import './styles.scss' @@ -36,5 +37,11 @@ export default { ctx.app.component('Since', Since) ctx.app.component('UnderConstruction', UnderConstruction) ctx.app.use(TwoslashFloatingVue) + + // Setup code group tab synchronization + setupCodeGroupSync() + + // Reinitialize on route changes (SPA navigation) + ctx.router.onAfterRouteChange = () => onRouteChange() } } \ No newline at end of file