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:
Loading...
+ + 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,