From 5dd431023dd457cb4fa5303d9e4af2314c7c7382 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Mon, 27 Apr 2026 14:17:23 +0200 Subject: [PATCH 1/7] 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 From df9d67512ad79a5c51dca7ca100bcf6664b74295 Mon Sep 17 00:00:00 2001 From: Johannes Vogt Date: Tue, 5 May 2026 15:43:45 +0200 Subject: [PATCH 2/7] Auto toggle scroll (#2545) Co-authored-by: Christian Georgi --- .vitepress/lib/restoreCodeGroupPreferences.js | 65 ++++++++++++++++++- .vitepress/lib/useCodeGroupSync.ts | 16 +++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/.vitepress/lib/restoreCodeGroupPreferences.js b/.vitepress/lib/restoreCodeGroupPreferences.js index f3202783e8..d189c47610 100644 --- a/.vitepress/lib/restoreCodeGroupPreferences.js +++ b/.vitepress/lib/restoreCodeGroupPreferences.js @@ -60,7 +60,7 @@ } return typeof parsed === 'object' ? parsed : {} } - } catch (e) { + } catch { // localStorage might not be available or JSON parse failed } return {} @@ -77,7 +77,7 @@ } } keysToRemove.forEach(key => localStorage.removeItem(key)) - } catch (e) { + } catch { // localStorage might not be available } } @@ -154,14 +154,60 @@ }) } + // VitePress's default scrollOffset (134) accounts for the fixed header + // and padding. This must match VitePress's getScrollOffset() to ensure + // consistent scroll positions between hash-link clicks and page reloads. + const getScrollOffset = () => 134 + + // Function to scroll to hash (matches VitePress's scrollTo logic) + const scrollToHash = (hash) => { + try { + const target = document.getElementById(decodeURIComponent(hash).slice(1)) + if (target) { + const targetPadding = parseInt(window.getComputedStyle(target).paddingTop, 10) + const targetTop = window.scrollY + + target.getBoundingClientRect().top - + getScrollOffset() + + targetPadding + + window.scrollTo(0, targetTop) + } + } catch { /* ignore invalid hash */ } + } + const applyToAllCodeGroups = () => { const codeGroups = document.querySelectorAll('.vp-code-group') codeGroups.forEach(applyToCodeGroup) } + // Track if we need to restore hash scroll + const initialHash = window.location.hash + let hashScrollPending = false + + if (initialHash) { + // Clear hash to prevent browser's auto-scroll + history.replaceState(null, '', window.location.pathname + window.location.search) + hashScrollPending = true + } + + const restoreHashScroll = () => { + if (hashScrollPending) { + // Restore hash and scroll immediately + history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) + // Scroll on next frame to let layout settle + requestAnimationFrame(() => { + scrollToHash(initialHash) + hashScrollPending = false + }) + } + } + // Apply immediately to any existing code groups (runs synchronously) applyToAllCodeGroups() + // If we have code groups and a hash, restore scroll now + if (document.querySelectorAll('.vp-code-group').length > 0) restoreHashScroll() + // Watch for code groups being added dynamically (SPA navigation, HMR in dev mode) const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { @@ -169,9 +215,17 @@ if (node instanceof HTMLElement) { if (node.classList?.contains('vp-code-group')) { applyToCodeGroup(node) + + // This might be the last code group, try to scroll + restoreHashScroll() } else if (node.querySelector) { const codeGroups = node.querySelectorAll('.vp-code-group') codeGroups.forEach(applyToCodeGroup) + + // Try to scroll after processing all code groups + if (codeGroups.length > 0) { + restoreHashScroll() + } } } } @@ -188,6 +242,11 @@ // Apply again on DOMContentLoaded as safety net if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', applyToAllCodeGroups) + document.addEventListener('DOMContentLoaded', () => { + applyToAllCodeGroups() + + // Final attempt to restore hash scroll if still pending + restoreHashScroll() + }) } })() diff --git a/.vitepress/lib/useCodeGroupSync.ts b/.vitepress/lib/useCodeGroupSync.ts index 7192172e3b..5f2154db9e 100644 --- a/.vitepress/lib/useCodeGroupSync.ts +++ b/.vitepress/lib/useCodeGroupSync.ts @@ -267,8 +267,24 @@ function setupEventListeners(): void { const tabLabel = (label.textContent || '').trim() if (!tabLabel) return + // Capture the viewport position of the clicked tab before syncing + const clickedRect = label.getBoundingClientRect() + // Sync all code groups with fuzzy matching syncTabs(tabLabel) + + // Restore scroll position to keep the clicked tab in view + requestAnimationFrame(() => { + const newRect = label.getBoundingClientRect() + const scrollDelta = newRect.top - clickedRect.top + + if (scrollDelta !== 0) { + window.scrollTo({ + top: (window.pageYOffset || document.documentElement.scrollTop) + scrollDelta, + behavior: 'instant' + }) + } + }) }) } From 61aa78239c37c13efc6e0aa307f21a3a5e126b7e Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Tue, 5 May 2026 17:04:31 +0200 Subject: [PATCH 3/7] Add CF/Kyma --- .vitepress/lib/restoreCodeGroupPreferences.js | 3 ++- .vitepress/lib/useCodeGroupSync.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.vitepress/lib/restoreCodeGroupPreferences.js b/.vitepress/lib/restoreCodeGroupPreferences.js index d189c47610..e6920bb585 100644 --- a/.vitepress/lib/restoreCodeGroupPreferences.js +++ b/.vitepress/lib/restoreCodeGroupPreferences.js @@ -18,7 +18,8 @@ // Note: Combinations like "macOS/Linux" are handled automatically by fuzzy matching const TAB_DIMENSIONS = { 'runtime': ['Node.js', 'Java'], - 'os': ['macOS', 'Windows', 'Linux'] + 'os': ['macOS', 'Windows', 'Linux'], + 'cloud-runtime': ['Cloud Foundry', 'Kyma'] } // Determine which dimension a tab belongs to (including fuzzy matches) diff --git a/.vitepress/lib/useCodeGroupSync.ts b/.vitepress/lib/useCodeGroupSync.ts index 5f2154db9e..df8077868f 100644 --- a/.vitepress/lib/useCodeGroupSync.ts +++ b/.vitepress/lib/useCodeGroupSync.ts @@ -18,7 +18,8 @@ // Note: Combinations like "macOS/Linux" are handled automatically by fuzzy matching const TAB_DIMENSIONS: Record = { 'runtime': ['Node.js', 'Java'], - 'os': ['macOS', 'Windows', 'Linux'] + 'os': ['macOS', 'Windows', 'Linux'], + 'cloud-runtime': ['Cloud Foundry', 'Kyma'] } interface CodeGroupInfo { From 0b6852c57f59946b4590cdfabaa577e3abd95c72 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Fri, 8 May 2026 11:39:14 +0200 Subject: [PATCH 4/7] Relax matching of tab titles --- .vitepress/lib/restoreCodeGroupPreferences.js | 9 +++++++++ .vitepress/lib/useCodeGroupSync.ts | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/.vitepress/lib/restoreCodeGroupPreferences.js b/.vitepress/lib/restoreCodeGroupPreferences.js index e6920bb585..ca4159664a 100644 --- a/.vitepress/lib/restoreCodeGroupPreferences.js +++ b/.vitepress/lib/restoreCodeGroupPreferences.js @@ -39,6 +39,15 @@ const tabsMatch = (tab1, tab2) => { if (tab1 === tab2) return true + // Relaxed matching for labels with additional context, + // for example: "mta.yaml (Cloud Foundry)" <-> "Cloud Foundry" + const normalized1 = tab1.trim().toLowerCase() + const normalized2 = tab2.trim().toLowerCase() + + if (normalized1 && normalized2 && (normalized1.includes(normalized2) || normalized2.includes(normalized1))) { + return true + } + // Split by "/" to get components const components1 = tab1.split('/').map(s => s.trim()) const components2 = tab2.split('/').map(s => s.trim()) diff --git a/.vitepress/lib/useCodeGroupSync.ts b/.vitepress/lib/useCodeGroupSync.ts index df8077868f..8c60b363ff 100644 --- a/.vitepress/lib/useCodeGroupSync.ts +++ b/.vitepress/lib/useCodeGroupSync.ts @@ -53,6 +53,15 @@ function getTabDimension(tabLabel: string): string | null { function tabsMatch(tab1: string, tab2: string): boolean { if (tab1 === tab2) return true + // Relaxed matching for labels with additional context, + // for example: "mta.yaml (Cloud Foundry)" <-> "Cloud Foundry" + const normalized1 = tab1.trim().toLowerCase() + const normalized2 = tab2.trim().toLowerCase() + + if (normalized1 && normalized2 && (normalized1.includes(normalized2) || normalized2.includes(normalized1))) { + return true + } + // Split by "/" to get components const components1 = tab1.split('/').map(s => s.trim()) const components2 = tab2.split('/').map(s => s.trim()) From 4603d61fbcbf3b171c220b6f1a39745c51e1b83e Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Mon, 11 May 2026 13:02:34 +0200 Subject: [PATCH 5/7] Code reorg --- .vitepress/config.js | 13 +- .../restoreCodeGroupPreferences.js | 144 +++++++ .vitepress/lib/code-groups/shared.js | 156 ++++++++ .../lib/code-groups/useCodeGroupSync.ts | 180 +++++++++ .vitepress/lib/restoreCodeGroupPreferences.js | 262 ------------- .vitepress/lib/useCodeGroupSync.ts | 371 ------------------ .vitepress/theme/index.ts | 2 +- 7 files changed, 493 insertions(+), 635 deletions(-) create mode 100644 .vitepress/lib/code-groups/restoreCodeGroupPreferences.js create mode 100644 .vitepress/lib/code-groups/shared.js create mode 100644 .vitepress/lib/code-groups/useCodeGroupSync.ts delete mode 100644 .vitepress/lib/restoreCodeGroupPreferences.js delete mode 100644 .vitepress/lib/useCodeGroupSync.ts diff --git a/.vitepress/config.js b/.vitepress/config.js index 83ff1d816e..9630ab6d53 100644 --- a/.vitepress/config.js +++ b/.vitepress/config.js @@ -11,6 +11,17 @@ import languages from './languages' import { Menu } from './menu.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const codeGroupSharedScript = readFileSync( + path.resolve(__dirname, './lib/code-groups/shared.js'), + 'utf-8' +).replace(/^export\s+/gm, '') +const codeGroupRestoreScript = readFileSync( + path.resolve(__dirname, './lib/code-groups/restoreCodeGroupPreferences.js'), + 'utf-8' +).replace( + '__CODE_GROUP_SHARED__', + codeGroupSharedScript +) const config = defineConfig({ @@ -83,7 +94,7 @@ const config = defineConfig({ // 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)}`], // Inline script to restore code group tab preferences (before Vue hydration) - ['script', {}, readFileSync(path.resolve(__dirname, './lib/restoreCodeGroupPreferences.js'), 'utf-8')] + ['script', {}, codeGroupRestoreScript] ], vite: { diff --git a/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js b/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js new file mode 100644 index 0000000000..b1060ee8f8 --- /dev/null +++ b/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js @@ -0,0 +1,144 @@ +;(() => { + // 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 + + __CODE_GROUP_SHARED__ + + // 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 { + // localStorage might not be available + } + } + + cleanupOldEntries() + + const activeTabs = getActiveTabsByDimension() + window.__CODE_GROUP_ACTIVE_TABS__ = activeTabs + + 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 + + const selectedTab = getBestTab(tabs, activeTabs) + const selectedIndex = tabs.indexOf(selectedTab) + + if (selectedIndex === -1) return + + setActiveTab(element, selectedIndex) + } + + const getScrollOffset = () => 134 + + const scrollToHash = (hash) => { + try { + const target = document.getElementById(decodeURIComponent(hash).slice(1)) + if (target) { + const targetPadding = parseInt(window.getComputedStyle(target).paddingTop, 10) + const targetTop = window.scrollY + + target.getBoundingClientRect().top - + getScrollOffset() + + targetPadding + + window.scrollTo(0, targetTop) + } + } catch { /* ignore invalid hash */ } + } + + const applyToAllCodeGroups = () => { + const codeGroups = document.querySelectorAll('.vp-code-group') + codeGroups.forEach(applyToCodeGroup) + + return codeGroups.length + } + + const initialHash = window.location.hash + let hashScrollPending = false + + if (initialHash) { + history.replaceState(null, '', window.location.pathname + window.location.search) + hashScrollPending = true + } + + const restoreHashScroll = () => { + if (hashScrollPending) { + history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) + requestAnimationFrame(() => { + scrollToHash(initialHash) + hashScrollPending = false + }) + } + } + + const initialCodeGroupCount = applyToAllCodeGroups() + + if (initialCodeGroupCount > 0) restoreHashScroll() + + let observer + const stopObserving = () => { + observer?.disconnect() + observer = null + } + + if (document.readyState === 'loading' || hashScrollPending) { + 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) + restoreHashScroll() + } else if (node.querySelector) { + const codeGroups = node.querySelectorAll('.vp-code-group') + codeGroups.forEach(applyToCodeGroup) + + if (codeGroups.length > 0) { + restoreHashScroll() + } + } + } + } + } + }) + + if (document.documentElement) { + observer.observe(document.documentElement, { + childList: true, + subtree: true + }) + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + applyToAllCodeGroups() + restoreHashScroll() + stopObserving() + }) + } else if (!hashScrollPending) { + stopObserving() + } +})() \ No newline at end of file diff --git a/.vitepress/lib/code-groups/shared.js b/.vitepress/lib/code-groups/shared.js new file mode 100644 index 0000000000..c8d734c105 --- /dev/null +++ b/.vitepress/lib/code-groups/shared.js @@ -0,0 +1,156 @@ +/** + * Shared helpers for code-group preference matching and activation. + */ + +export const STORAGE_KEY = 'code-group-active-tabs' + +export const TAB_DIMENSIONS = { + runtime: ['Node.js', 'Java'], + os: ['macOS', 'Windows', 'Linux'], + 'cloud-runtime': ['Cloud Foundry', 'Kyma'] +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +export function isTabMap(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +/** + * @param {string} tab1 + * @param {string} tab2 + */ +export function tabsMatch(tab1, tab2) { + if (tab1 === tab2) return true + + const normalized1 = tab1.trim().toLowerCase() + const normalized2 = tab2.trim().toLowerCase() + + if (normalized1 && normalized2 && (normalized1.includes(normalized2) || normalized2.includes(normalized1))) { + return true + } + + const components1 = tab1.split('/').map(s => s.trim()) + const components2 = tab2.split('/').map(s => s.trim()) + + return components1.some(c1 => components2.includes(c1)) || + components2.some(c2 => components1.includes(c2)) +} + +/** + * @param {string} tabLabel + * @returns {string | null} + */ +export function getTabDimension(tabLabel) { + for (const [dimension, tabs] of Object.entries(TAB_DIMENSIONS)) { + for (const dimTab of tabs) { + if (tabsMatch(tabLabel, dimTab)) { + return dimension + } + } + } + + return null +} + +/** + * @param {Record | undefined} [seedTabs] + * @returns {Record} + */ +export function getActiveTabsByDimension(seedTabs) { + const activeTabs = {} + + if (isTabMap(seedTabs)) { + Object.assign(activeTabs, seedTabs) + } + + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) + if (Array.isArray(parsed)) { + return activeTabs + } + if (isTabMap(parsed)) { + Object.assign(activeTabs, parsed) + } + } + } catch { + // localStorage might not be available or JSON parsing failed + } + + return activeTabs +} + +/** + * @param {Record} activeTabs + */ +export function saveActiveTabsByDimension(activeTabs) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(activeTabs)) + } catch { + // localStorage might not be available + } +} + +/** + * @param {string} tabLabel + */ +export function addActiveTab(tabLabel) { + const activeTabs = getActiveTabsByDimension() + const dimension = getTabDimension(tabLabel) + + if (dimension) { + activeTabs[dimension] = tabLabel + saveActiveTabsByDimension(activeTabs) + } + + return activeTabs +} + +/** + * @param {string[]} tabs + * @param {Record} [activeTabs] + */ +export function getBestTab(tabs, activeTabs = getActiveTabsByDimension()) { + for (const tab of tabs) { + const dimension = getTabDimension(tab) + if (dimension && activeTabs[dimension]) { + const activeTab = activeTabs[dimension] + if (tab === activeTab || tabsMatch(tab, activeTab)) { + return tab + } + } + } + + for (const tab of tabs) { + const dimension = getTabDimension(tab) + if (dimension && TAB_DIMENSIONS[dimension]) { + const defaultTab = TAB_DIMENSIONS[dimension][0] + if (tab === defaultTab || tabsMatch(tab, defaultTab)) { + return tab + } + } + } + + return tabs[0] +} + +/** + * @param {HTMLElement} element + * @param {number} activeIndex + */ +export function setActiveTab(element, activeIndex) { + const inputs = element.querySelectorAll('.tabs input') + const blocks = element.querySelectorAll('div[class*="language-"], .vp-block') + + inputs.forEach((input, index) => { + input.checked = index === activeIndex + }) + + blocks.forEach((block, index) => { + block.classList.toggle('active', index === activeIndex) + }) +} \ No newline at end of file diff --git a/.vitepress/lib/code-groups/useCodeGroupSync.ts b/.vitepress/lib/code-groups/useCodeGroupSync.ts new file mode 100644 index 0000000000..8efdbbd3c7 --- /dev/null +++ b/.vitepress/lib/code-groups/useCodeGroupSync.ts @@ -0,0 +1,180 @@ +/** + * 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) + * - Selecting a tab only updates its own dimension in persistent storage + */ + +import { + addActiveTab, + getActiveTabsByDimension, + getBestTab, + setActiveTab, + tabsMatch +} from './shared.js' + +interface CodeGroupInfo { + element: HTMLElement + tabs: string[] +} + +let hasClickListener = false +let codeGroupObserver: MutationObserver | null = null + +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 +} + +function applyPreference(codeGroup: CodeGroupInfo): void { + const { element, tabs } = codeGroup + const selectedTab = getBestTab( + tabs, + getActiveTabsByDimension((window as any).__CODE_GROUP_ACTIVE_TABS__) + ) + const selectedIndex = tabs.indexOf(selectedTab) + + if (selectedIndex !== -1) { + setActiveTab(element, selectedIndex) + } +} + +function syncTabs(selectedTab: string): void { + const codeGroups = findCodeGroups() + + codeGroups.forEach((codeGroup) => { + const matchingTab = codeGroup.tabs.find(tab => tabsMatch(tab, selectedTab)) + + if (matchingTab) { + const { element, tabs } = codeGroup + const tabIndex = tabs.indexOf(matchingTab) + + if (tabIndex !== -1) { + setActiveTab(element, tabIndex) + } + } + }) + + ;(window as any).__CODE_GROUP_ACTIVE_TABS__ = addActiveTab(selectedTab) +} + +function handleDocumentClick(event: Event): void { + const target = event.target as HTMLElement | null + const label = target?.closest('.vp-code-group .tabs label') as HTMLLabelElement | null + if (!label) return + + const codeGroup = target?.closest('.vp-code-group') as HTMLElement | null + if (!codeGroup) return + + const tabLabel = (label.textContent || '').trim() + if (!tabLabel) return + + const clickedRect = label.getBoundingClientRect() + + syncTabs(tabLabel) + + requestAnimationFrame(() => { + const newRect = label.getBoundingClientRect() + const scrollDelta = newRect.top - clickedRect.top + + if (scrollDelta !== 0) { + window.scrollTo({ + top: (window.pageYOffset || document.documentElement.scrollTop) + scrollDelta, + behavior: 'instant' + }) + } + }) +} + +function ensureClickListener(enabled: boolean): void { + if (enabled && !hasClickListener) { + document.addEventListener('click', handleDocumentClick) + hasClickListener = true + return + } + + if (!enabled && hasClickListener) { + document.removeEventListener('click', handleDocumentClick) + hasClickListener = false + } +} + +function startObservingCodeGroups(): void { + if (codeGroupObserver || !document.body) return + + codeGroupObserver = new MutationObserver((mutations) => { + let shouldReapply = false + + for (const mutation of mutations) { + if (mutation.addedNodes.length === 0) continue + + for (const node of mutation.addedNodes) { + if (node instanceof HTMLElement && + (node.classList?.contains('vp-code-group') || node.querySelector?.('.vp-code-group'))) { + shouldReapply = true + break + } + } + + if (shouldReapply) break + } + + if (shouldReapply) { + reinitCodeGroupSync() + } + }) + + codeGroupObserver.observe(document.body, { + childList: true, + subtree: true + }) +} + +function initCodeGroupSync(): void { + startObservingCodeGroups() + + const codeGroups = findCodeGroups() + codeGroups.forEach(applyPreference) + ensureClickListener(codeGroups.length > 0) +} + +function reinitCodeGroupSync(): void { + const codeGroups = findCodeGroups() + codeGroups.forEach(applyPreference) + ensureClickListener(codeGroups.length > 0) +} + +export function setupCodeGroupSync(): void { + if (typeof window === 'undefined') return + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => initCodeGroupSync(), { once: true }) + } else { + initCodeGroupSync() + } +} + +export function onRouteChange(): void { + if (typeof window !== 'undefined') { + setTimeout(() => { reinitCodeGroupSync() }, 0) + } +} \ No newline at end of file diff --git a/.vitepress/lib/restoreCodeGroupPreferences.js b/.vitepress/lib/restoreCodeGroupPreferences.js deleted file mode 100644 index ca4159664a..0000000000 --- a/.vitepress/lib/restoreCodeGroupPreferences.js +++ /dev/null @@ -1,262 +0,0 @@ -;(() => { - // 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'], - 'cloud-runtime': ['Cloud Foundry', 'Kyma'] - } - - // 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 - - // Relaxed matching for labels with additional context, - // for example: "mta.yaml (Cloud Foundry)" <-> "Cloud Foundry" - const normalized1 = tab1.trim().toLowerCase() - const normalized2 = tab2.trim().toLowerCase() - - if (normalized1 && normalized2 && (normalized1.includes(normalized2) || normalized2.includes(normalized1))) { - 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 { - // 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 { - // 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') - } - }) - } - - // VitePress's default scrollOffset (134) accounts for the fixed header - // and padding. This must match VitePress's getScrollOffset() to ensure - // consistent scroll positions between hash-link clicks and page reloads. - const getScrollOffset = () => 134 - - // Function to scroll to hash (matches VitePress's scrollTo logic) - const scrollToHash = (hash) => { - try { - const target = document.getElementById(decodeURIComponent(hash).slice(1)) - if (target) { - const targetPadding = parseInt(window.getComputedStyle(target).paddingTop, 10) - const targetTop = window.scrollY + - target.getBoundingClientRect().top - - getScrollOffset() + - targetPadding - - window.scrollTo(0, targetTop) - } - } catch { /* ignore invalid hash */ } - } - - const applyToAllCodeGroups = () => { - const codeGroups = document.querySelectorAll('.vp-code-group') - codeGroups.forEach(applyToCodeGroup) - } - - // Track if we need to restore hash scroll - const initialHash = window.location.hash - let hashScrollPending = false - - if (initialHash) { - // Clear hash to prevent browser's auto-scroll - history.replaceState(null, '', window.location.pathname + window.location.search) - hashScrollPending = true - } - - const restoreHashScroll = () => { - if (hashScrollPending) { - // Restore hash and scroll immediately - history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) - // Scroll on next frame to let layout settle - requestAnimationFrame(() => { - scrollToHash(initialHash) - hashScrollPending = false - }) - } - } - - // Apply immediately to any existing code groups (runs synchronously) - applyToAllCodeGroups() - - // If we have code groups and a hash, restore scroll now - if (document.querySelectorAll('.vp-code-group').length > 0) restoreHashScroll() - - // 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) - - // This might be the last code group, try to scroll - restoreHashScroll() - } else if (node.querySelector) { - const codeGroups = node.querySelectorAll('.vp-code-group') - codeGroups.forEach(applyToCodeGroup) - - // Try to scroll after processing all code groups - if (codeGroups.length > 0) { - restoreHashScroll() - } - } - } - } - } - }) - - // 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() - - // Final attempt to restore hash scroll if still pending - restoreHashScroll() - }) - } -})() diff --git a/.vitepress/lib/useCodeGroupSync.ts b/.vitepress/lib/useCodeGroupSync.ts deleted file mode 100644 index 8c60b363ff..0000000000 --- a/.vitepress/lib/useCodeGroupSync.ts +++ /dev/null @@ -1,371 +0,0 @@ -/** - * 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'], - 'cloud-runtime': ['Cloud Foundry', 'Kyma'] -} - -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 - - // Relaxed matching for labels with additional context, - // for example: "mta.yaml (Cloud Foundry)" <-> "Cloud Foundry" - const normalized1 = tab1.trim().toLowerCase() - const normalized2 = tab2.trim().toLowerCase() - - if (normalized1 && normalized2 && (normalized1.includes(normalized2) || normalized2.includes(normalized1))) { - 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 - - // Capture the viewport position of the clicked tab before syncing - const clickedRect = label.getBoundingClientRect() - - // Sync all code groups with fuzzy matching - syncTabs(tabLabel) - - // Restore scroll position to keep the clicked tab in view - requestAnimationFrame(() => { - const newRect = label.getBoundingClientRect() - const scrollDelta = newRect.top - clickedRect.top - - if (scrollDelta !== 0) { - window.scrollTo({ - top: (window.pageYOffset || document.documentElement.scrollTop) + scrollDelta, - behavior: 'instant' - }) - } - }) - }) -} - -/** - * 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 1bc3806ba1..3e04212ca9 100644 --- a/.vitepress/theme/index.ts +++ b/.vitepress/theme/index.ts @@ -12,7 +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 { setupCodeGroupSync, onRouteChange } from '../lib/code-groups/useCodeGroupSync' import '@shikijs/vitepress-twoslash/style.css' import './styles.scss' From dd95bd96aa1bd88fa4f0acb23769a27a6b796574 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Mon, 11 May 2026 14:03:42 +0200 Subject: [PATCH 6/7] Fix eslint errors --- .vitepress/lib/code-groups/restoreCodeGroupPreferences.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js b/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js index b1060ee8f8..1268e3823b 100644 --- a/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js +++ b/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js @@ -12,6 +12,7 @@ // - Storage format: { "runtime": "Java", "os": "macOS" } // - First entry in each dimension array is the default + // eslint-disable-next-line no-undef __CODE_GROUP_SHARED__ // Clean up old localStorage entries from previous implementation @@ -32,7 +33,7 @@ cleanupOldEntries() - const activeTabs = getActiveTabsByDimension() + const activeTabs = getActiveTabsByDimension() // eslint-disable-line no-undef window.__CODE_GROUP_ACTIVE_TABS__ = activeTabs const applyToCodeGroup = (element) => { @@ -43,12 +44,12 @@ if (tabs.length === 0) return - const selectedTab = getBestTab(tabs, activeTabs) + const selectedTab = getBestTab(tabs, activeTabs) // eslint-disable-line no-undef const selectedIndex = tabs.indexOf(selectedTab) if (selectedIndex === -1) return - setActiveTab(element, selectedIndex) + setActiveTab(element, selectedIndex) // eslint-disable-line no-undef } const getScrollOffset = () => 134 From c2dcaf179771bf5d51774dc79c1fc122eecc763b Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Mon, 11 May 2026 14:09:07 +0200 Subject: [PATCH 7/7] More cleanup --- .vitepress/config.js | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/.vitepress/config.js b/.vitepress/config.js index 9630ab6d53..4266e5873c 100644 --- a/.vitepress/config.js +++ b/.vitepress/config.js @@ -2,7 +2,7 @@ const base = process.env.GH_BASE || '/docs/' // Construct vitepress config object... -import path from 'node:path' +import { dirname, join, resolve } from 'node:path' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitepress' @@ -10,18 +10,9 @@ 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 codeGroupSharedScript = readFileSync( - path.resolve(__dirname, './lib/code-groups/shared.js'), - 'utf-8' -).replace(/^export\s+/gm, '') -const codeGroupRestoreScript = readFileSync( - path.resolve(__dirname, './lib/code-groups/restoreCodeGroupPreferences.js'), - 'utf-8' -).replace( - '__CODE_GROUP_SHARED__', - codeGroupSharedScript -) +const __dirname = dirname(fileURLToPath(import.meta.url)) +const codeGrSharedScript = readFileSync(resolve(__dirname, './lib/code-groups/shared.js'),'utf-8').replace(/^export\s+/gm, '') +const codeGrRestoreScript = readFileSync(resolve(__dirname, './lib/code-groups/restoreCodeGroupPreferences.js'),'utf-8').replace('__CODE_GROUP_SHARED__', codeGrSharedScript) const config = defineConfig({ @@ -94,7 +85,7 @@ const config = defineConfig({ // 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)}`], // Inline script to restore code group tab preferences (before Vue hydration) - ['script', {}, codeGroupRestoreScript] + ['script', {}, codeGrRestoreScript] ], vite: { @@ -130,7 +121,7 @@ import rewrites from './rewrites' config.rewrites = rewrites // Read menu from local menu.md, but only if we run standalone, not embeded as @external -if (process.cwd() === path.dirname(__dirname)) { +if (process.cwd() === dirname(__dirname)) { const menu = await Menu.from ('./menu.md', rewrites) config.themeConfig.sidebar = menu.items config.themeConfig.nav = menu.navbar @@ -157,8 +148,8 @@ if (process.env.VITE_CAPIRE_PREVIEW) { // Add link to survey if (process.env.NODE_ENV !== 'production') { // open in VS Code - const home = path.resolve(__dirname, '..') - let href = 'vscode://' + path.join('file', home, encodeURIComponent('${filePath}')).replaceAll(/\\/g, '/').replace('@external/', '') + const home = resolve(__dirname, '..') + let href = 'vscode://' + join('file', home, encodeURIComponent('${filePath}')).replaceAll(/\\/g, '/').replace('@external/', '') config.themeConfig.capire.gotoLinks.push({ href, key: 'o', name: 'VS Code' }) } @@ -236,13 +227,13 @@ import { promises as fs } from 'node:fs' import * as cdsMavenSite from './lib/cds-maven-site' config.buildEnd = async ({ outDir, site }) => { const sitemapURL = new URL(config.themeConfig.capire.siteURL.href) - sitemapURL.pathname = path.join(sitemapURL.pathname, 'sitemap.xml') + sitemapURL.pathname = join(sitemapURL.pathname, 'sitemap.xml') console.debug('✓ writing robots.txt with sitemap URL', sitemapURL.href) // eslint-disable-line no-console - const robots = (await fs.readFile(path.resolve(__dirname, 'robots.txt'))).toString().replace('{{SITEMAP}}', sitemapURL.href) - await fs.writeFile(path.join(outDir, 'robots.txt'), robots) + const robots = (await fs.readFile(resolve(__dirname, 'robots.txt'))).toString().replace('{{SITEMAP}}', sitemapURL.href) + await fs.writeFile(join(outDir, 'robots.txt'), robots) // disabled by default to avoid online fetches during local build if (process.env.VITE_CAPIRE_EXTRA_ASSETS) { - await cdsMavenSite.copySiteAssets(path.join(outDir, 'java/assets/cds-maven-plugin-site'), site) + await cdsMavenSite.copySiteAssets(join(outDir, 'java/assets/cds-maven-plugin-site'), site) } }