diff --git a/.vitepress/config.js b/.vitepress/config.js index 4b9b2d721..4266e5873 100644 --- a/.vitepress/config.js +++ b/.vitepress/config.js @@ -2,12 +2,18 @@ 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' import playground from './lib/cds-playground/index.js' import languages from './languages' import { Menu } from './menu.js' +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({ title: 'capire', @@ -77,7 +83,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', {}, codeGrRestoreScript] ], vite: { @@ -113,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 @@ -140,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' }) } @@ -219,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) } } diff --git a/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js b/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js new file mode 100644 index 000000000..1268e3823 --- /dev/null +++ b/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js @@ -0,0 +1,145 @@ +;(() => { + // 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 + + // eslint-disable-next-line no-undef + __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() // eslint-disable-line no-undef + 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) // eslint-disable-line no-undef + const selectedIndex = tabs.indexOf(selectedTab) + + if (selectedIndex === -1) return + + setActiveTab(element, selectedIndex) // eslint-disable-line no-undef + } + + 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 000000000..c8d734c10 --- /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 000000000..8efdbbd3c --- /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/theme/index.ts b/.vitepress/theme/index.ts index 18433e6ad..3e04212ca 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/code-groups/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