diff --git a/demo/+docpress.tsx b/demo/+docpress.tsx index 6c0a5e51..dc85a574 100644 --- a/demo/+docpress.tsx +++ b/demo/+docpress.tsx @@ -45,6 +45,20 @@ const config: Config = { // globalNote: , topNavigation: , navMaxWidth: 1140, + choices: { + 'pkg-manager': { + choices: ['npm', 'pnpm', 'yarn', 'bun'], + default: 'npm', + }, + server: { + choices: ['hono', 'express', 'fastify'], + default: 'hono', + }, + 'ui-ext': { + choices: ['vike-react', 'vike-vue', 'vike-solid'], + default: 'vike-react', + }, + }, } /* diff --git a/demo/pages/features/+Page.mdx b/demo/pages/features/+Page.mdx index f617f0e9..f2e6be32 100644 --- a/demo/pages/features/+Page.mdx +++ b/demo/pages/features/+Page.mdx @@ -220,3 +220,149 @@ async function onNewTodo(text: string) { // https://vike.dev const a: number = 1 ``` + +## ChoiceGroup + +Using `choice=choice-name` meta to group code blocks into multiple choices: + +```shell choice=hono +npm i hono @photonjs/hono +``` + +```shell choice=express +npm i express @photonjs/express +``` + +```ts choice=hono file-added +// server/index.ts + +import { Hono } from 'hono' +import { apply, serve } from 'vike-photon/hono' + +function startServer() { + const app = new Hono() + apply(app) + return serve(app) +} + +export default startServer() +``` + +```ts choice=express file-added +// server/index.ts + +import express from 'express' +import { apply, serve } from 'vike-photon/express' + +function startServer() { + const app = express() + apply(app) + return serve(app) +} + +export default startServer() +``` + +Skipping the first choice from `+docpress.choices['server']` caused it to become the third disabled option in the select options. + +```ts choice=express file-added +// server/index.ts + +import express from 'express' +import { apply, serve } from 'vike-photon/express' + +function startServer() { + const app = express() + apply(app) + return serve(app) +} + +export default startServer() +``` + +```ts choice=fastify file-added +// server/index.ts + +import fastify from 'fastify' +import rawBody from 'fastify-raw-body' +import { apply, serve } from '@photonjs/fastify' + +async function startServer() { + const app = fastify({ + // ⚠️ Mandatory for HMR support + forceCloseConnections: true + }) + // ⚠️ Mandatory for Vike's SSR middleware + await app.register(rawBody) + await apply(app) + return serve(app) +} + +export default startServer() +``` + +Some paragraph. + +:::Choice{#express} +> Express.js is deprecated. +::: + + +Using `:::Choice{#choice-name} {:mdx}` directive to combine multiple contents for a single choice: + +:::Choice{#vike-react} +```tsx +import { ClientOnly } from 'vike-react/ClientOnly' + +function Page() { + return ( + Loading...

}> + +
+ ) +} +``` + +Props: +- **children**: Content rendered only on the client-side after hydration. +- **fallback** (optional): Content shown during SSR and before hydration completes. +::: + +:::Choice{#vike-vue} +```vue + + + +``` + +Props: +- **default slot**: Content rendered only on the client-side after hydration. +- **fallback slot** (optional): Content shown during SSR and before hydration completes. +::: + +:::Choice{#vike-solid} +```tsx +import { ClientOnly } from 'vike-solid/ClientOnly' + +function Page() { + return ( + Loading...

}> + +
+ ) +} +``` + +Props: +- **children**: Content rendered only on the client-side after hydration. +- **fallback** (optional): Content shown during SSR and before hydration completes. +::: \ No newline at end of file diff --git a/demo/testRun.ts b/demo/testRun.ts index 470fe6cf..dfba0c83 100644 --- a/demo/testRun.ts +++ b/demo/testRun.ts @@ -152,6 +152,92 @@ function testRun(cmd: 'pnpm run dev' | 'pnpm run preview') { } }) + test(`${featuresURL} - Choice Group`, async () => { + const firstChoiceText1 = 'npm i hono @photonjs/hono' + const firstChoiceText2 = "import { Hono } from 'hono'" + const secondChoiceText1 = 'pnpm add express @photonjs/express' + const secondChoiceText2 = "import express from 'express'" + const fastifyChoiceText = "import fastify from 'fastify'" + + const hasFirstChoice = (text: string | null, yes = true) => { + expect(text).not.toBe(null) + if (yes) { + expect(text).toContain(firstChoiceText1) + expect(text).toContain(firstChoiceText2) + } else { + expect(text).not.toContain(firstChoiceText1) + expect(text).not.toContain(firstChoiceText2) + } + } + + const hasSecondChoice = (text: string | null, yes = true) => { + expect(text).not.toBe(null) + if (yes) { + expect(text).toContain(secondChoiceText1) + expect(text).toContain(secondChoiceText2) + } else { + expect(text).not.toContain(secondChoiceText1) + expect(text).not.toContain(secondChoiceText2) + } + } + + const hasFastifyChoice = (text: string | null, yes = true) => { + expect(text).not.toBe(null) + if (yes) { + expect(text).toContain(fastifyChoiceText) + } else { + expect(text).not.toContain(fastifyChoiceText) + } + } + + const textFull = await page.textContent('body') + + hasFirstChoice(textFull) + hasSecondChoice(textFull) + hasFastifyChoice(textFull) + + const expectFirstChoice = async () => { + const text = await getVisibleText(page) + hasFirstChoice(text) + hasSecondChoice(text, false) + hasFastifyChoice(text, false) + } + + const expectSecondChoice = async () => { + const text = await getVisibleText(page) + hasFirstChoice(text, false) + hasSecondChoice(text) + hasFastifyChoice(text, false) + } + + const expectFastifyChoice = async () => { + await page.locator('select[name="server-choices"]:visible').nth(2).selectOption('fastify') + const text = await getVisibleText(page) + hasFirstChoice(text, false) + hasSecondChoice(text, false) + hasFastifyChoice(text) + } + + await page.evaluate(() => window.localStorage.clear()) + + await expectFirstChoice() + + await page.selectOption(`select[name="pkg-manager-choices"]:visible`, { index: isDev ? 0 : 1 }) + await page.selectOption(`select[name="server-choices"]:visible`, { index: isDev ? 0 : 1 }) + + await autoRetry( + async () => { + if (isDev) { + await expectFirstChoice() + } else { + await expectSecondChoice() + await expectFastifyChoice() + } + }, + { timeout: 5 * 1000 }, + ) + }) + const somePageUrl = '/some-page' test(`${somePageUrl} - custom
 injected into nested MDX`, async () => {
     await page.goto(getServerUrl() + somePageUrl)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 933f4297..f2f9bf4a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -87,6 +87,9 @@ importers:
       detype:
         specifier: ^1.1.3
         version: 1.1.3
+      npm-to-yarn:
+        specifier: ^3.0.1
+        version: 3.0.1
       react:
         specifier: '>=18.0.0'
         version: 19.2.0
@@ -96,6 +99,9 @@ importers:
       rehype-pretty-code:
         specifier: 0.13.0
         version: 0.13.0(shiki@1.2.0)
+      remark-directive:
+        specifier: ^4.0.0
+        version: 4.0.0
       remark-gfm:
         specifier: 4.0.0
         version: 4.0.0
@@ -133,6 +139,9 @@ importers:
       '@types/react-dom':
         specifier: ^19.2.2
         version: 19.2.2(@types/react@19.2.2)
+      mdast-util-directive:
+        specifier: ^3.1.0
+        version: 3.1.0
       mdast-util-mdx-jsx:
         specifier: ^3.2.0
         version: 3.2.0
@@ -1719,6 +1728,9 @@ packages:
     resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
     engines: {node: '>= 0.4'}
 
+  mdast-util-directive@3.1.0:
+    resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==}
+
   mdast-util-find-and-replace@3.0.2:
     resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
 
@@ -1781,6 +1793,9 @@ packages:
   micromark-core-commonmark@2.0.3:
     resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
 
+  micromark-extension-directive@4.0.0:
+    resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==}
+
   micromark-extension-gfm-autolink-literal@2.1.0:
     resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
 
@@ -1920,6 +1935,10 @@ packages:
     resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
     engines: {node: '>=8'}
 
+  npm-to-yarn@3.0.1:
+    resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
   object-inspect@1.13.4:
     resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
     engines: {node: '>= 0.4'}
@@ -2041,6 +2060,9 @@ packages:
     peerDependencies:
       shiki: ^1.0.0
 
+  remark-directive@4.0.0:
+    resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==}
+
   remark-gfm@4.0.0:
     resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==}
 
@@ -4153,6 +4175,20 @@ snapshots:
 
   math-intrinsics@1.1.0: {}
 
+  mdast-util-directive@3.1.0:
+    dependencies:
+      '@types/mdast': 4.0.4
+      '@types/unist': 3.0.3
+      ccount: 2.0.1
+      devlop: 1.1.0
+      mdast-util-from-markdown: 2.0.2
+      mdast-util-to-markdown: 2.1.2
+      parse-entities: 4.0.2
+      stringify-entities: 4.0.4
+      unist-util-visit-parents: 6.0.1
+    transitivePeerDependencies:
+      - supports-color
+
   mdast-util-find-and-replace@3.0.2:
     dependencies:
       '@types/mdast': 4.0.4
@@ -4341,6 +4377,16 @@ snapshots:
       micromark-util-symbol: 2.0.1
       micromark-util-types: 2.0.2
 
+  micromark-extension-directive@4.0.0:
+    dependencies:
+      devlop: 1.1.0
+      micromark-factory-space: 2.0.1
+      micromark-factory-whitespace: 2.0.1
+      micromark-util-character: 2.1.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+      parse-entities: 4.0.2
+
   micromark-extension-gfm-autolink-literal@2.1.0:
     dependencies:
       micromark-util-character: 2.1.1
@@ -4615,6 +4661,8 @@ snapshots:
     dependencies:
       path-key: 3.1.1
 
+  npm-to-yarn@3.0.1: {}
+
   object-inspect@1.13.4: {}
 
   object-keys@1.1.1: {}
@@ -4743,6 +4791,15 @@ snapshots:
       unified: 11.0.5
       unist-util-visit: 5.0.0
 
+  remark-directive@4.0.0:
+    dependencies:
+      '@types/mdast': 4.0.4
+      mdast-util-directive: 3.1.0
+      micromark-extension-directive: 4.0.0
+      unified: 11.0.5
+    transitivePeerDependencies:
+      - supports-color
+
   remark-gfm@4.0.0:
     dependencies:
       '@types/mdast': 4.0.4
diff --git a/src/Layout.tsx b/src/Layout.tsx
index 2564c751..ad8074c2 100644
--- a/src/Layout.tsx
+++ b/src/Layout.tsx
@@ -27,6 +27,7 @@ import {
 } from './MenuModal/toggleMenuModal'
 import { MenuModal } from './MenuModal'
 import { autoScrollNav_SSR } from './autoScrollNav'
+import { initializeChoiceGroup_SSR } from './code-blocks/hooks/useSelectedChoice'
 import { initializeJsToggle_SSR } from './code-blocks/hooks/useSelectCodeLang'
 import { SearchLink } from './docsearch/SearchLink'
 import { navigate } from 'vike/client/router'
@@ -100,7 +101,7 @@ function Layout({ children }: { children: React.ReactNode }) {
         {content}
       
       {/* Early toggling, to avoid layout jumps */}
-      
+      
       
     
   )
@@ -589,7 +590,10 @@ function NavHeadLogo({ isNavLeft }: { isNavLeft?: true }) {
               paddingLeft: 'var(--main-view-padding)',
               paddingRight: 'var(--padding-side)',
             }
-          : {}),
+          : {
+              paddingLeft: 15,
+              marginLeft: -10,
+            }),
       }}
       href="/"
       onContextMenu={!navLogo ? undefined : onContextMenu}
diff --git a/src/code-blocks/components/ChoiceGroup.css b/src/code-blocks/components/ChoiceGroup.css
new file mode 100644
index 00000000..cac05ceb
--- /dev/null
+++ b/src/code-blocks/components/ChoiceGroup.css
@@ -0,0 +1,48 @@
+.choice-group {
+  position: relative;
+
+  &:hover {
+    .select-choice,
+    .copy-button,
+    .code-lang-toggle {
+      opacity: 1;
+    }
+  }
+
+  --select-width: 100px;
+  --select-top-position: 10px;
+  --select-right-position: 42px;
+  --has-toggle: calc(var(--select-right-position) + 61px);
+
+  .select-choice {
+    position: absolute;
+    z-index: 3;
+    height: 25px;
+    top: var(--select-top-position);
+    right: var(--select-right-position);
+    width: var(--select-width);
+
+    opacity: 0;
+    transition: opacity 0.5s ease-in-out, background-color 0.4s ease-in-out;
+
+    &.has-toggle {
+      --select-right-position: var(--has-toggle);
+    }
+
+    &:has(+ .choice .choice-group) {
+      right: calc(var(--select-width) + var(--select-right-position) + 2px);
+    }
+  }
+
+  .hidden {
+    display: none !important;
+  }
+
+  select:has(option:nth-of-type(1):not(:checked)) ~ .choice:nth-of-type(1),
+  select:has(option:nth-of-type(2):not(:checked)) ~ .choice:nth-of-type(2),
+  select:has(option:nth-of-type(3):not(:checked)) ~ .choice:nth-of-type(3),
+  select:has(option:nth-of-type(4):not(:checked)) ~ .choice:nth-of-type(4),
+  select:has(option:nth-of-type(5):not(:checked)) ~ .choice:nth-of-type(5) {
+    display: none;
+  }
+}
diff --git a/src/code-blocks/components/ChoiceGroup.tsx b/src/code-blocks/components/ChoiceGroup.tsx
new file mode 100644
index 00000000..9ad505a3
--- /dev/null
+++ b/src/code-blocks/components/ChoiceGroup.tsx
@@ -0,0 +1,85 @@
+export { ChoiceGroup }
+
+import React, { useEffect, useRef, useState } from 'react'
+import { usePageContext } from '../../renderer/usePageContext'
+import { useSelectedChoice } from '../hooks/useSelectedChoice'
+import { useRestoreScroll } from '../hooks/useRestoreScroll'
+import { assertUsage } from '../../utils/assert'
+import { cls } from '../../utils/cls'
+import type { PageContext } from 'vike/types'
+import './ChoiceGroup.css'
+
+function ChoiceGroup({ children, choices }: { children: React.ReactNode; choices: string[] }) {
+  const pageContext = usePageContext()
+  const group = findGroup(pageContext, choices)
+  const [selectedChoice, setSelectedChoice] = useSelectedChoice(group.name, group.default)
+  const [hasJsToggle, setHasJsToggle] = useState(false)
+  const choiceGroupRef = useRef(null)
+  const prevPositionRef = useRestoreScroll([selectedChoice])
+  const isHidden = choices.length === 1 || !choices.includes(selectedChoice)
+
+  useEffect(() => {
+    if (!choiceGroupRef.current) return
+    const selectedChoiceEl = choiceGroupRef.current.querySelector(`div[id="${selectedChoice}"]`)
+    setHasJsToggle(!!selectedChoiceEl?.classList.contains('has-toggle'))
+  }, [selectedChoice])
+
+  return (
+    
+ + {children} +
+ ) + + function onChange(e: React.ChangeEvent) { + const el = e.target + prevPositionRef.current = { top: el.getBoundingClientRect().top, el } + setSelectedChoice(el.value) + } +} + +function findGroup(pageContext: PageContext, choices: string[]) { + const { choices: choicesGroup } = pageContext.globalContext.config.docpress + assertUsage(choicesGroup, `+docpress.choices is not defined.`) + + const groupName = Object.keys(choicesGroup).find((key) => { + // get only the values that exist in both choices and choicesGroup[key].choices + const relevantChoices = choicesGroup[key].choices.filter((choice) => choices.includes(choice)) + // if nothing exists, skip this key + if (relevantChoices.length === 0) return false + + // check order + let i = 0 + for (const choice of choices) { + if (choice === relevantChoices[i]) i++ + } + assertUsage( + i === relevantChoices.length, + `Choices exist for key "${key}" but NOT in order. Expected order: [${relevantChoices}], got: [${choices}]`, + ) + + return true + }) + assertUsage(groupName, `the group name for [${choices}] was not found.`) + + const mergedChoices = [...new Set([...choices, ...choicesGroup[groupName].choices])] + + const group = { + name: groupName, + ...choicesGroup[groupName], + choices: mergedChoices, + } + + return group +} diff --git a/src/code-blocks/components/CodeSnippets.tsx b/src/code-blocks/components/CodeSnippets.tsx index 56901e79..e92973e4 100644 --- a/src/code-blocks/components/CodeSnippets.tsx +++ b/src/code-blocks/components/CodeSnippets.tsx @@ -4,8 +4,9 @@ export { TypescriptOnly } // Internal export { CodeSnippets } -import React, { useEffect, useRef } from 'react' +import React from 'react' import { useSelectCodeLang } from '../hooks/useSelectCodeLang' +import { useRestoreScroll } from '../hooks/useRestoreScroll' import './CodeSnippets.css' /** Only show if TypeScript is selected */ @@ -16,18 +17,7 @@ function TypescriptOnly({ children }: { children: React.ReactNode }) { function CodeSnippets({ children, hideToggle = false }: { children: React.ReactNode; hideToggle: boolean }) { const [codeLangSelected, selectCodeLang] = useSelectCodeLang() - const prevPositionRef = useRef(null) - - // Restores the scroll position of the toggle element after toggling languages. - useEffect(() => { - if (!prevPositionRef.current) return - const { top, el } = prevPositionRef.current - const delta = el.getBoundingClientRect().top - top - if (delta !== 0) { - window.scrollBy(0, delta) - } - prevPositionRef.current = null - }, [codeLangSelected]) + const prevPositionRef = useRestoreScroll([codeLangSelected]) return (
diff --git a/src/code-blocks/components/Pre.tsx b/src/code-blocks/components/Pre.tsx index 0c8e308d..b03e17ac 100644 --- a/src/code-blocks/components/Pre.tsx +++ b/src/code-blocks/components/Pre.tsx @@ -1,11 +1,35 @@ export { Pre } -import React, { ComponentPropsWithoutRef, useState } from 'react' +import React, { useState } from 'react' +import { cls } from '../../utils/cls' import './Pre.css' -function Pre({ children, ...props }: ComponentPropsWithoutRef<'pre'> & { 'hide-menu'?: string }) { +// Styling defined in src/css/code/diff.css +const classRemoved = [ + // + 'diff-entire-file', + 'diff-entire-file-removed', +].join(' ') +const classAdded = [ + // + 'diff-entire-file', + 'diff-entire-file-added', +].join(' ') + +type AdditionalProps = { + 'hide-menu'?: string + 'file-added'?: string + 'file-removed'?: string +} + +function Pre({ children, ...props }: React.ComponentPropsWithoutRef<'pre'> & AdditionalProps) { + const { className, ...rest } = props + return ( -
+    
       {!props['hide-menu'] && }
       {children}
     
diff --git a/src/code-blocks/hooks/useLocalStorage.ts b/src/code-blocks/hooks/useLocalStorage.ts new file mode 100644 index 00000000..4c775891 --- /dev/null +++ b/src/code-blocks/hooks/useLocalStorage.ts @@ -0,0 +1,39 @@ +export { useLocalStorage } + +import { useCallback, useSyncExternalStore } from 'react' + +/** + * A simple, generic `useLocalStorage` hook with SSR and cross-tab support. + * + * @param storageKey The key used in localStorage. + * @param clientValue Default value for the client. + * @param ssrValue Optional fallback for server-side rendering. + * @returns A tuple `[value, setValue]`. + */ +function useLocalStorage(storageKey: string, clientValue: string, ssrValue?: string) { + const subscribe = useCallback( + (callback: () => void) => { + const listener = (e: StorageEvent) => { + if (e.key === storageKey) callback() + } + window.addEventListener('storage', listener) + return () => window.removeEventListener('storage', listener) + }, + [storageKey], + ) + + const getSnapshot = useCallback(() => { + const storedValue = localStorage.getItem(storageKey) + return storedValue || clientValue + }, [storageKey, clientValue]) + + const setValue = (value: string) => { + localStorage.setItem(storageKey, value) + // Manually dispatch a storage event to force update in the current tab + window.dispatchEvent(new StorageEvent('storage', { key: storageKey })) + } + + const value = useSyncExternalStore(subscribe, getSnapshot, () => ssrValue || clientValue) + + return [value, setValue] as const +} diff --git a/src/code-blocks/hooks/useMDXComponents.tsx b/src/code-blocks/hooks/useMDXComponents.tsx index 2e6f511c..56ec3e74 100644 --- a/src/code-blocks/hooks/useMDXComponents.tsx +++ b/src/code-blocks/hooks/useMDXComponents.tsx @@ -4,9 +4,11 @@ import React from 'react' import type { UseMdxComponents } from '@mdx-js/mdx' import { Pre } from '../components/Pre.js' import { CodeSnippets } from '../components/CodeSnippets.js' +import { ChoiceGroup } from '../components/ChoiceGroup.js' const useMDXComponents: UseMdxComponents = () => { return { + ChoiceGroup, CodeSnippets, pre: (props) =>
,
   }
diff --git a/src/code-blocks/hooks/useRestoreScroll.ts b/src/code-blocks/hooks/useRestoreScroll.ts
new file mode 100644
index 00000000..f445a29b
--- /dev/null
+++ b/src/code-blocks/hooks/useRestoreScroll.ts
@@ -0,0 +1,31 @@
+export { useRestoreScroll }
+
+import React, { useEffect, useRef } from 'react'
+
+type ScrollPosition = { top: number; el: Element }
+
+/**
+ * useRestoreScroll
+ *
+ * Keeps the page from jumping when content changes,
+ * preserving the user’s scroll position.
+ *
+ * @param deps Dependencies that trigger scroll restoration
+ * @returns Ref holding the tracked element and its previous top position
+ */
+function useRestoreScroll(deps: React.DependencyList) {
+  const prevPositionRef = useRef(null)
+
+  useEffect(() => {
+    if (!prevPositionRef.current) return
+
+    const { top, el } = prevPositionRef.current
+    const delta = el.getBoundingClientRect().top - top
+
+    if (delta !== 0) window.scrollBy(0, delta)
+
+    prevPositionRef.current = null
+  }, deps)
+
+  return prevPositionRef
+}
diff --git a/src/code-blocks/hooks/useSelectCodeLang.ts b/src/code-blocks/hooks/useSelectCodeLang.ts
index 491cef8b..6f78f484 100644
--- a/src/code-blocks/hooks/useSelectCodeLang.ts
+++ b/src/code-blocks/hooks/useSelectCodeLang.ts
@@ -1,57 +1,14 @@
 export { useSelectCodeLang }
 export { initializeJsToggle_SSR }
 
-import { useState, useEffect, useCallback } from 'react'
-import { assertWarning } from '../../utils/assert'
+import { useLocalStorage } from './useLocalStorage'
 
 const storageKey = 'docpress:code-lang'
 const codeLangDefaultSsr = 'ts'
 const codeLangDefaultClient = 'js'
 
 function useSelectCodeLang() {
-  const [codeLangSelected, setCodeLangSelected] = useState(codeLangDefaultSsr)
-  const updateState = () => {
-    setCodeLangSelected(getCodeLangStorage())
-  }
-  const updateStateOnStorageEvent = (event: StorageEvent) => {
-    if (event.key === storageKey) updateState()
-  }
-
-  const getCodeLangStorage = () => {
-    try {
-      return window.localStorage.getItem(storageKey) ?? codeLangDefaultClient
-    } catch (error) {
-      console.error(error)
-      assertWarning(false, 'Error reading from localStorage')
-      return codeLangDefaultClient
-    }
-  }
-
-  const selectCodeLang = useCallback((value: string) => {
-    try {
-      window.localStorage.setItem(storageKey, value)
-      setCodeLangSelected(value)
-      window.dispatchEvent(new CustomEvent('code-lang-storage'))
-    } catch (error) {
-      console.error(error)
-      assertWarning(false, 'Error setting localStorage')
-    }
-  }, [])
-
-  useEffect(() => {
-    // Initial load from localStorage
-    updateState()
-    // Update code lang in current tab
-    window.addEventListener('code-lang-storage', updateState)
-    // Update code lang if changed in another tab
-    window.addEventListener('storage', updateStateOnStorageEvent)
-    return () => {
-      window.removeEventListener('code-lang-storage', updateState)
-      window.removeEventListener('storage', updateStateOnStorageEvent)
-    }
-  }, [])
-
-  return [codeLangSelected, selectCodeLang] as const
+  return useLocalStorage(storageKey, codeLangDefaultClient, codeLangDefaultSsr)
 }
 
 // WARNING: We cannot use the variables storageKey nor codeLangDefaultClient here: closures
@@ -66,9 +23,3 @@ function initializeJsToggle() {
     for (const input of inputs) input.checked = false
   }
 }
-
-declare global {
-  interface WindowEventMap {
-    'code-lang-storage': CustomEvent
-  }
-}
diff --git a/src/code-blocks/hooks/useSelectedChoice.ts b/src/code-blocks/hooks/useSelectedChoice.ts
new file mode 100644
index 00000000..f8b0ab03
--- /dev/null
+++ b/src/code-blocks/hooks/useSelectedChoice.ts
@@ -0,0 +1,34 @@
+export { useSelectedChoice }
+export { initializeChoiceGroup_SSR }
+
+import { useState } from 'react'
+import { useLocalStorage } from './useLocalStorage'
+
+const keyPrefix = 'docpress'
+
+/**
+ * Tracks the selected choice.
+ * Uses `useLocalStorage` if `persistId` is provided, otherwise regular state.
+ *
+ * @param persistId Optional ID to persist selection.
+ * @param defaultValue Default choice value.
+ * @returns `[selectedChoice, setSelectedChoice]`.
+ */
+function useSelectedChoice(persistId: string | null, defaultValue: string) {
+  if (!persistId) return useState(defaultValue)
+
+  return useLocalStorage(`${keyPrefix}:${persistId}`, defaultValue)
+}
+
+// WARNING: We cannot use the keyPrefix variable here: closures don't work because we serialize the function.
+const initializeChoiceGroup_SSR = `initializeChoiceGroup();${initializeChoiceGroup.toString()};`
+function initializeChoiceGroup() {
+  const groupsElements = document.querySelectorAll('[data-group-name]')
+  for (const groupEl of groupsElements) {
+    const groupName = groupEl.getAttribute('data-group-name')!
+    const selectedChoice = localStorage.getItem(`docpress:${groupName}`)
+    if (!selectedChoice) continue
+    const selectEl = groupEl.querySelector(`.select-choice`)
+    if (selectEl) selectEl.value = selectedChoice
+  }
+}
diff --git a/src/code-blocks/remarkChoiceGroup.ts b/src/code-blocks/remarkChoiceGroup.ts
new file mode 100644
index 00000000..aee64330
--- /dev/null
+++ b/src/code-blocks/remarkChoiceGroup.ts
@@ -0,0 +1,112 @@
+export { remarkChoiceGroup }
+
+import type { Code, Root } from 'mdast'
+import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx'
+import type { ContainerDirective } from 'mdast-util-directive'
+import { visit } from 'unist-util-visit'
+import { parseMetaString } from './rehypeMetaToProps.js'
+import { generateChoiceGroup } from './utils/generateChoiceGroup.js'
+
+type Node = Code | MdxJsxFlowElement | ContainerDirective
+
+function remarkChoiceGroup() {
+  return function (tree: Root) {
+    visit(tree, (node) => {
+      if (node.type === 'code') {
+        if (!node.meta) return
+        const meta = parseMetaString(node.meta, ['choice'])
+        const { choice } = meta.props
+        node.meta = meta.rest
+
+        if (choice) node.data ??= { choice }
+      }
+      if (node.type === 'containerDirective' && node.name === 'Choice') {
+        if (!node.attributes) return
+        const { id: choice } = node.attributes
+        if (choice) {
+          node.data ??= { choice }
+          node.attributes = {}
+        }
+      }
+    })
+
+    const replaced = new WeakSet()
+    visit(tree, (node) => {
+      if (!('children' in node) || replaced.has(node)) return 'skip'
+      if (node.type === 'mdxJsxFlowElement') return 'skip'
+
+      let start = -1
+      let end = 0
+
+      const process = () => {
+        if (start === -1 || start === end) return
+        const nodes = node.children.slice(start, end) as Node[]
+        const groupedNodes = groupByNodeType(nodes)
+        const replacements: MdxJsxFlowElement[] = []
+
+        for (const groupedNode of groupedNodes) {
+          const replacement = generateChoiceGroup(groupedNode)
+
+          replacements.push(replacement)
+          replaced.add(replacement)
+        }
+
+        node.children.splice(start, end - start, ...replacements)
+
+        end = start
+        start = -1
+      }
+
+      for (; end < node.children.length; end++) {
+        const child = node.children[end]
+
+        if (!['code', 'mdxJsxFlowElement', 'containerDirective'].includes(child.type)) {
+          process()
+          continue
+        }
+
+        if (!child.data?.choice) {
+          process()
+          continue
+        }
+
+        if (start === -1) start = end
+      }
+
+      process()
+    })
+  }
+}
+
+type NodeGroup = {
+  value: string
+  children: Node[]
+}
+
+function groupByNodeType(nodes: Node[]) {
+  const groupedNodes = new Set()
+  const filters = [...new Set(nodes.flat().map((node) => (node.type === 'code' ? node.lang! : node.name)))]
+
+  filters.map((filter) => {
+    const nodesByChoice = new Map()
+    nodes
+      .filter((node) => (node.type === 'code' ? node.lang! : node.name) === filter)
+      .map((node) => {
+        const choice = node.data!.choice!
+        const nodes = nodesByChoice.get(choice) ?? []
+        nodes.push(node)
+        node.data = {}
+        nodesByChoice.set(choice, nodes)
+      })
+
+    groupedNodes.add([...nodesByChoice].map(([name, nodes]) => ({ value: name, children: nodes })))
+  })
+
+  return [...groupedNodes]
+}
+
+declare module 'mdast' {
+  export interface Data {
+    choice?: string
+  }
+}
diff --git a/src/code-blocks/remarkDetype.ts b/src/code-blocks/remarkDetype.ts
index c3f55952..24701d6c 100644
--- a/src/code-blocks/remarkDetype.ts
+++ b/src/code-blocks/remarkDetype.ts
@@ -82,8 +82,11 @@ function transformYaml(node: CodeNode) {
 
 async function transformTsToJs(node: CodeNode, file: VFile) {
   const { codeBlock, index, parent } = node
-  const meta = parseMetaString(codeBlock.meta)
+  const meta = parseMetaString(codeBlock.meta, ['max-width', 'choice'])
   const maxWidth = Number(meta.props['max-width'])
+  const { choice } = meta.props
+  codeBlock.meta = meta.rest
+
   let codeBlockReplacedJs = replaceFileNameSuffixes(codeBlock.value)
   let codeBlockContentJs = ''
 
@@ -137,8 +140,8 @@ async function transformTsToJs(node: CodeNode, file: VFile) {
   // Add `hideToggle` attribute (prop) to `CodeSnippets` if the only change was replacing `.ts` with `.js`
   if (codeBlockReplacedJs === codeBlockContentJs) {
     attributes.push({
-      name: 'hideToggle',
       type: 'mdxJsxAttribute',
+      name: 'hideToggle',
     })
   }
 
@@ -149,6 +152,9 @@ async function transformTsToJs(node: CodeNode, file: VFile) {
     children: [jsCode, codeBlock],
     attributes,
   }
+
+  if (choice) container.data ??= { choice }
+
   parent.children.splice(index, 1, container)
 }
 
diff --git a/src/code-blocks/remarkPkgManager.ts b/src/code-blocks/remarkPkgManager.ts
new file mode 100644
index 00000000..b1500c0d
--- /dev/null
+++ b/src/code-blocks/remarkPkgManager.ts
@@ -0,0 +1,45 @@
+export { remarkPkgManager }
+
+import type { Code, Root } from 'mdast'
+import { visit } from 'unist-util-visit'
+import convert from 'npm-to-yarn'
+import { parseMetaString } from './rehypeMetaToProps.js'
+import { generateChoiceGroup } from './utils/generateChoiceGroup.js'
+
+const PKG_MANAGERS = ['pnpm', 'yarn', 'bun'] as const
+
+function remarkPkgManager() {
+  return function (tree: Root) {
+    visit(tree, 'code', (node, index, parent) => {
+      if (!parent || typeof index === 'undefined') return
+      if (!['sh', 'shell'].includes(node.lang || '')) return
+      if (node.value.indexOf('npm') === -1 && node.value.indexOf('npx') === -1) return
+
+      let choice: string | undefined = undefined
+      const nodes = new Map()
+
+      if (node.meta) {
+        const meta = parseMetaString(node.meta, ['choice'])
+        choice = meta.props['choice']
+        node.meta = meta.rest
+      }
+
+      nodes.set('npm', node)
+
+      for (const pm of PKG_MANAGERS) {
+        nodes.set(pm, {
+          type: node.type,
+          lang: node.lang,
+          meta: node.meta,
+          value: convert(node.value, pm),
+        })
+      }
+
+      const groupedNodes = [...nodes].map(([name, node]) => ({ value: name, children: [node] }))
+      const replacement = generateChoiceGroup(groupedNodes)
+
+      replacement.data ??= { choice }
+      parent.children.splice(index, 1, replacement)
+    })
+  }
+}
diff --git a/src/code-blocks/utils/generateChoiceGroup.ts b/src/code-blocks/utils/generateChoiceGroup.ts
new file mode 100644
index 00000000..93b32f39
--- /dev/null
+++ b/src/code-blocks/utils/generateChoiceGroup.ts
@@ -0,0 +1,87 @@
+export { generateChoiceGroup }
+export type { CodeChoice }
+
+import type { BlockContent, DefinitionContent } from 'mdast'
+import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx-jsx'
+
+type CodeChoice = {
+  value: string
+  children: (BlockContent | DefinitionContent)[]
+}
+
+function generateChoiceGroup(codeChoices: CodeChoice[]): MdxJsxFlowElement {
+  const attributes: MdxJsxAttribute[] = []
+  const children: MdxJsxFlowElement[] = []
+
+  attributes.push({
+    type: 'mdxJsxAttribute',
+    name: 'choices',
+    value: {
+      type: 'mdxJsxAttributeValueExpression',
+      value: '',
+      data: {
+        estree: {
+          type: 'Program',
+          sourceType: 'module',
+          comments: [],
+          body: [
+            {
+              type: 'ExpressionStatement',
+              expression: {
+                type: 'ArrayExpression',
+                // @ts-ignore: Missing properties in type definition
+                elements: codeChoices.map((choice) => ({
+                  type: 'Literal',
+                  value: choice.value,
+                })),
+              },
+            },
+          ],
+        },
+      },
+    },
+  })
+
+  for (const codeChoice of codeChoices) {
+    const classNames = ['choice']
+    if (findHasJsToggle(codeChoice.children[0])) {
+      classNames.push('has-toggle')
+    }
+
+    children.push({
+      type: 'mdxJsxFlowElement',
+      name: 'div',
+      attributes: [
+        { type: 'mdxJsxAttribute', name: 'id', value: codeChoice.value },
+        { type: 'mdxJsxAttribute', name: 'className', value: classNames.join(' ') },
+      ],
+      children: codeChoice.children.every((node) => node.type === 'containerDirective')
+        ? codeChoice.children.flatMap((node) => [...node.children])
+        : codeChoice.children,
+    })
+  }
+
+  return {
+    type: 'mdxJsxFlowElement',
+    name: 'ChoiceGroup',
+    attributes,
+    children,
+  }
+}
+
+function findHasJsToggle(node: BlockContent | DefinitionContent) {
+  if (node.type === 'containerDirective' && node.name === 'Choice') {
+    return (
+      node.children[0].type === 'mdxJsxFlowElement' &&
+      node.children[0].name === 'CodeSnippets' &&
+      node.children[0].attributes.every(
+        (attribute) => attribute.type !== 'mdxJsxAttribute' || attribute.name !== 'hideToggle',
+      )
+    )
+  }
+  return (
+    node.type === 'mdxJsxFlowElement' &&
+    node.name === 'CodeSnippets' &&
+    node.attributes.every((attribute) => attribute.type !== 'mdxJsxAttribute' || attribute.name !== 'hideToggle')
+  )
+}
diff --git a/src/package.json b/src/package.json
index 5a62af0e..2b7a17c7 100644
--- a/src/package.json
+++ b/src/package.json
@@ -24,7 +24,9 @@
     "@shikijs/transformers": "1.2.0",
     "@vitejs/plugin-react-swc": "^3.10.2",
     "detype": "^1.1.3",
+    "npm-to-yarn": "^3.0.1",
     "rehype-pretty-code": "0.13.0",
+    "remark-directive": "^4.0.0",
     "remark-gfm": "4.0.0",
     "shiki": "1.2.0",
     "unist-util-visit": "^5.0.0",
@@ -77,6 +79,7 @@
     "@types/node": "^24.10.0",
     "@types/react": "^19.2.2",
     "@types/react-dom": "^19.2.2",
+    "mdast-util-directive": "^3.1.0",
     "mdast-util-mdx-jsx": "^3.2.0"
   },
   "repository": "https://github.com/brillout/docpress",
diff --git a/src/types/Config.ts b/src/types/Config.ts
index 50463581..8d83e206 100644
--- a/src/types/Config.ts
+++ b/src/types/Config.ts
@@ -48,6 +48,7 @@ type Config = {
   navLogoTextStyle?: React.CSSProperties
 
   globalNote?: React.ReactNode
+  choices?: Record
 }
 
 /** Order in Algolia search results */
@@ -58,3 +59,8 @@ type Category =
       /** Hide from Algolia search */
       hide?: boolean
     }
+
+type Choice = {
+  choices: string[]
+  default: string
+}
diff --git a/src/vite.config.ts b/src/vite.config.ts
index 30a85b54..de5a762a 100644
--- a/src/vite.config.ts
+++ b/src/vite.config.ts
@@ -6,10 +6,13 @@ import type { PluginOption, UserConfig } from 'vite'
 import { parsePageSections } from './parsePageSections.js'
 import rehypePrettyCode from 'rehype-pretty-code'
 import remarkGfm from 'remark-gfm'
+import remarkDirective from 'remark-directive'
 import { transformerNotationDiff } from '@shikijs/transformers'
 import { rehypeMetaToProps } from './code-blocks/rehypeMetaToProps.js'
 import { remarkDetype } from './code-blocks/remarkDetype.js'
 import { shikiTransformerAutoLinks } from './code-blocks/shikiTransformerAutoLinks.js'
+import { remarkPkgManager } from './code-blocks/remarkPkgManager.js'
+import { remarkChoiceGroup } from './code-blocks/remarkChoiceGroup.js'
 
 const root = process.cwd()
 const prettyCode = [
@@ -21,7 +24,7 @@ const prettyCode = [
   },
 ]
 const rehypePlugins: any = [prettyCode, [rehypeMetaToProps]]
-const remarkPlugins = [remarkGfm, remarkDetype]
+const remarkPlugins = [remarkGfm, remarkDirective, remarkDetype, remarkPkgManager, remarkChoiceGroup]
 
 const config: UserConfig = {
   root,