@brillout/docpress 0.16.7 → 0.16.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Layout.tsx CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  } from './MenuModal/toggleMenuModal'
28
28
  import { MenuModal } from './MenuModal'
29
29
  import { autoScrollNav_SSR } from './autoScrollNav'
30
+ import { initializeChoiceGroup_SSR } from './code-blocks/hooks/useSelectedChoice'
30
31
  import { initializeJsToggle_SSR } from './code-blocks/hooks/useSelectCodeLang'
31
32
  import { SearchLink } from './docsearch/SearchLink'
32
33
  import { navigate } from 'vike/client/router'
@@ -100,7 +101,7 @@ function Layout({ children }: { children: React.ReactNode }) {
100
101
  {content}
101
102
  </div>
102
103
  {/* Early toggling, to avoid layout jumps */}
103
- <script dangerouslySetInnerHTML={{ __html: initializeJsToggle_SSR }}></script>
104
+ <script dangerouslySetInnerHTML={{ __html: `${initializeChoiceGroup_SSR}\n${initializeJsToggle_SSR}` }}></script>
104
105
  <Style>{getStyleLayout()}</Style>
105
106
  </div>
106
107
  )
@@ -589,7 +590,10 @@ function NavHeadLogo({ isNavLeft }: { isNavLeft?: true }) {
589
590
  paddingLeft: 'var(--main-view-padding)',
590
591
  paddingRight: 'var(--padding-side)',
591
592
  }
592
- : {}),
593
+ : {
594
+ paddingLeft: 15,
595
+ marginLeft: -10,
596
+ }),
593
597
  }}
594
598
  href="/"
595
599
  onContextMenu={!navLogo ? undefined : onContextMenu}
@@ -0,0 +1,48 @@
1
+ .choice-group {
2
+ position: relative;
3
+
4
+ &:hover {
5
+ .select-choice,
6
+ .copy-button,
7
+ .code-lang-toggle {
8
+ opacity: 1;
9
+ }
10
+ }
11
+
12
+ --select-width: 100px;
13
+ --select-top-position: 10px;
14
+ --select-right-position: 42px;
15
+ --has-toggle: calc(var(--select-right-position) + 61px);
16
+
17
+ .select-choice {
18
+ position: absolute;
19
+ z-index: 3;
20
+ height: 25px;
21
+ top: var(--select-top-position);
22
+ right: var(--select-right-position);
23
+ width: var(--select-width);
24
+
25
+ opacity: 0;
26
+ transition: opacity 0.5s ease-in-out, background-color 0.4s ease-in-out;
27
+
28
+ &.has-toggle {
29
+ --select-right-position: var(--has-toggle);
30
+ }
31
+
32
+ &:has(+ .choice .choice-group) {
33
+ right: calc(var(--select-width) + var(--select-right-position) + 2px);
34
+ }
35
+ }
36
+
37
+ .hidden {
38
+ display: none !important;
39
+ }
40
+
41
+ select:has(option:nth-of-type(1):not(:checked)) ~ .choice:nth-of-type(1),
42
+ select:has(option:nth-of-type(2):not(:checked)) ~ .choice:nth-of-type(2),
43
+ select:has(option:nth-of-type(3):not(:checked)) ~ .choice:nth-of-type(3),
44
+ select:has(option:nth-of-type(4):not(:checked)) ~ .choice:nth-of-type(4),
45
+ select:has(option:nth-of-type(5):not(:checked)) ~ .choice:nth-of-type(5) {
46
+ display: none;
47
+ }
48
+ }
@@ -0,0 +1,85 @@
1
+ export { ChoiceGroup }
2
+
3
+ import React, { useEffect, useRef, useState } from 'react'
4
+ import { usePageContext } from '../../renderer/usePageContext'
5
+ import { useSelectedChoice } from '../hooks/useSelectedChoice'
6
+ import { useRestoreScroll } from '../hooks/useRestoreScroll'
7
+ import { assertUsage } from '../../utils/assert'
8
+ import { cls } from '../../utils/cls'
9
+ import type { PageContext } from 'vike/types'
10
+ import './ChoiceGroup.css'
11
+
12
+ function ChoiceGroup({ children, choices }: { children: React.ReactNode; choices: string[] }) {
13
+ const pageContext = usePageContext()
14
+ const group = findGroup(pageContext, choices)
15
+ const [selectedChoice, setSelectedChoice] = useSelectedChoice(group.name, group.default)
16
+ const [hasJsToggle, setHasJsToggle] = useState(false)
17
+ const choiceGroupRef = useRef<HTMLDivElement>(null)
18
+ const prevPositionRef = useRestoreScroll([selectedChoice])
19
+ const isHidden = choices.length === 1 || !choices.includes(selectedChoice)
20
+
21
+ useEffect(() => {
22
+ if (!choiceGroupRef.current) return
23
+ const selectedChoiceEl = choiceGroupRef.current.querySelector<HTMLDivElement>(`div[id="${selectedChoice}"]`)
24
+ setHasJsToggle(!!selectedChoiceEl?.classList.contains('has-toggle'))
25
+ }, [selectedChoice])
26
+
27
+ return (
28
+ <div ref={choiceGroupRef} data-group-name={group.name} className="choice-group">
29
+ <select
30
+ name={`${group.name}-choices`}
31
+ value={selectedChoice}
32
+ onChange={onChange}
33
+ className={cls(['select-choice', hasJsToggle && 'has-toggle', isHidden && 'hidden'])}
34
+ >
35
+ {group.choices.map((choice, i) => (
36
+ <option key={i} value={choice} disabled={!choices.includes(choice)}>
37
+ {choice}
38
+ </option>
39
+ ))}
40
+ </select>
41
+ {children}
42
+ </div>
43
+ )
44
+
45
+ function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
46
+ const el = e.target
47
+ prevPositionRef.current = { top: el.getBoundingClientRect().top, el }
48
+ setSelectedChoice(el.value)
49
+ }
50
+ }
51
+
52
+ function findGroup(pageContext: PageContext, choices: string[]) {
53
+ const { choices: choicesGroup } = pageContext.globalContext.config.docpress
54
+ assertUsage(choicesGroup, `+docpress.choices is not defined.`)
55
+
56
+ const groupName = Object.keys(choicesGroup).find((key) => {
57
+ // get only the values that exist in both choices and choicesGroup[key].choices
58
+ const relevantChoices = choicesGroup[key].choices.filter((choice) => choices.includes(choice))
59
+ // if nothing exists, skip this key
60
+ if (relevantChoices.length === 0) return false
61
+
62
+ // check order
63
+ let i = 0
64
+ for (const choice of choices) {
65
+ if (choice === relevantChoices[i]) i++
66
+ }
67
+ assertUsage(
68
+ i === relevantChoices.length,
69
+ `Choices exist for key "${key}" but NOT in order. Expected order: [${relevantChoices}], got: [${choices}]`,
70
+ )
71
+
72
+ return true
73
+ })
74
+ assertUsage(groupName, `the group name for [${choices}] was not found.`)
75
+
76
+ const mergedChoices = [...new Set([...choices, ...choicesGroup[groupName].choices])]
77
+
78
+ const group = {
79
+ name: groupName,
80
+ ...choicesGroup[groupName],
81
+ choices: mergedChoices,
82
+ }
83
+
84
+ return group
85
+ }
@@ -4,8 +4,9 @@ export { TypescriptOnly }
4
4
  // Internal
5
5
  export { CodeSnippets }
6
6
 
7
- import React, { useEffect, useRef } from 'react'
7
+ import React from 'react'
8
8
  import { useSelectCodeLang } from '../hooks/useSelectCodeLang'
9
+ import { useRestoreScroll } from '../hooks/useRestoreScroll'
9
10
  import './CodeSnippets.css'
10
11
 
11
12
  /** Only show if TypeScript is selected */
@@ -16,18 +17,7 @@ function TypescriptOnly({ children }: { children: React.ReactNode }) {
16
17
 
17
18
  function CodeSnippets({ children, hideToggle = false }: { children: React.ReactNode; hideToggle: boolean }) {
18
19
  const [codeLangSelected, selectCodeLang] = useSelectCodeLang()
19
- const prevPositionRef = useRef<null | { top: number; el: Element }>(null)
20
-
21
- // Restores the scroll position of the toggle element after toggling languages.
22
- useEffect(() => {
23
- if (!prevPositionRef.current) return
24
- const { top, el } = prevPositionRef.current
25
- const delta = el.getBoundingClientRect().top - top
26
- if (delta !== 0) {
27
- window.scrollBy(0, delta)
28
- }
29
- prevPositionRef.current = null
30
- }, [codeLangSelected])
20
+ const prevPositionRef = useRestoreScroll([codeLangSelected])
31
21
 
32
22
  return (
33
23
  <div className="code-snippets">
@@ -1,11 +1,35 @@
1
1
  export { Pre }
2
2
 
3
- import React, { ComponentPropsWithoutRef, useState } from 'react'
3
+ import React, { useState } from 'react'
4
+ import { cls } from '../../utils/cls'
4
5
  import './Pre.css'
5
6
 
6
- function Pre({ children, ...props }: ComponentPropsWithoutRef<'pre'> & { 'hide-menu'?: string }) {
7
+ // Styling defined in src/css/code/diff.css
8
+ const classRemoved = [
9
+ //
10
+ 'diff-entire-file',
11
+ 'diff-entire-file-removed',
12
+ ].join(' ')
13
+ const classAdded = [
14
+ //
15
+ 'diff-entire-file',
16
+ 'diff-entire-file-added',
17
+ ].join(' ')
18
+
19
+ type AdditionalProps = {
20
+ 'hide-menu'?: string
21
+ 'file-added'?: string
22
+ 'file-removed'?: string
23
+ }
24
+
25
+ function Pre({ children, ...props }: React.ComponentPropsWithoutRef<'pre'> & AdditionalProps) {
26
+ const { className, ...rest } = props
27
+
7
28
  return (
8
- <pre {...props}>
29
+ <pre
30
+ className={cls([className, props['file-added'] && classAdded, props['file-removed'] && classRemoved])}
31
+ {...rest}
32
+ >
9
33
  {!props['hide-menu'] && <CopyButton />}
10
34
  {children}
11
35
  </pre>
@@ -0,0 +1,39 @@
1
+ export { useLocalStorage }
2
+
3
+ import { useCallback, useSyncExternalStore } from 'react'
4
+
5
+ /**
6
+ * A simple, generic `useLocalStorage` hook with SSR and cross-tab support.
7
+ *
8
+ * @param storageKey The key used in localStorage.
9
+ * @param clientValue Default value for the client.
10
+ * @param ssrValue Optional fallback for server-side rendering.
11
+ * @returns A tuple `[value, setValue]`.
12
+ */
13
+ function useLocalStorage(storageKey: string, clientValue: string, ssrValue?: string) {
14
+ const subscribe = useCallback(
15
+ (callback: () => void) => {
16
+ const listener = (e: StorageEvent) => {
17
+ if (e.key === storageKey) callback()
18
+ }
19
+ window.addEventListener('storage', listener)
20
+ return () => window.removeEventListener('storage', listener)
21
+ },
22
+ [storageKey],
23
+ )
24
+
25
+ const getSnapshot = useCallback(() => {
26
+ const storedValue = localStorage.getItem(storageKey)
27
+ return storedValue || clientValue
28
+ }, [storageKey, clientValue])
29
+
30
+ const setValue = (value: string) => {
31
+ localStorage.setItem(storageKey, value)
32
+ // Manually dispatch a storage event to force update in the current tab
33
+ window.dispatchEvent(new StorageEvent('storage', { key: storageKey }))
34
+ }
35
+
36
+ const value = useSyncExternalStore(subscribe, getSnapshot, () => ssrValue || clientValue)
37
+
38
+ return [value, setValue] as const
39
+ }
@@ -4,9 +4,11 @@ import React from 'react'
4
4
  import type { UseMdxComponents } from '@mdx-js/mdx'
5
5
  import { Pre } from '../components/Pre.js'
6
6
  import { CodeSnippets } from '../components/CodeSnippets.js'
7
+ import { ChoiceGroup } from '../components/ChoiceGroup.js'
7
8
 
8
9
  const useMDXComponents: UseMdxComponents = () => {
9
10
  return {
11
+ ChoiceGroup,
10
12
  CodeSnippets,
11
13
  pre: (props) => <Pre {...props} />,
12
14
  }
@@ -0,0 +1,31 @@
1
+ export { useRestoreScroll }
2
+
3
+ import React, { useEffect, useRef } from 'react'
4
+
5
+ type ScrollPosition = { top: number; el: Element }
6
+
7
+ /**
8
+ * useRestoreScroll
9
+ *
10
+ * Keeps the page from jumping when content changes,
11
+ * preserving the user’s scroll position.
12
+ *
13
+ * @param deps Dependencies that trigger scroll restoration
14
+ * @returns Ref holding the tracked element and its previous top position
15
+ */
16
+ function useRestoreScroll(deps: React.DependencyList) {
17
+ const prevPositionRef = useRef<ScrollPosition | null>(null)
18
+
19
+ useEffect(() => {
20
+ if (!prevPositionRef.current) return
21
+
22
+ const { top, el } = prevPositionRef.current
23
+ const delta = el.getBoundingClientRect().top - top
24
+
25
+ if (delta !== 0) window.scrollBy(0, delta)
26
+
27
+ prevPositionRef.current = null
28
+ }, deps)
29
+
30
+ return prevPositionRef
31
+ }
@@ -1,57 +1,14 @@
1
1
  export { useSelectCodeLang }
2
2
  export { initializeJsToggle_SSR }
3
3
 
4
- import { useState, useEffect, useCallback } from 'react'
5
- import { assertWarning } from '../../utils/assert'
4
+ import { useLocalStorage } from './useLocalStorage'
6
5
 
7
6
  const storageKey = 'docpress:code-lang'
8
7
  const codeLangDefaultSsr = 'ts'
9
8
  const codeLangDefaultClient = 'js'
10
9
 
11
10
  function useSelectCodeLang() {
12
- const [codeLangSelected, setCodeLangSelected] = useState(codeLangDefaultSsr)
13
- const updateState = () => {
14
- setCodeLangSelected(getCodeLangStorage())
15
- }
16
- const updateStateOnStorageEvent = (event: StorageEvent) => {
17
- if (event.key === storageKey) updateState()
18
- }
19
-
20
- const getCodeLangStorage = () => {
21
- try {
22
- return window.localStorage.getItem(storageKey) ?? codeLangDefaultClient
23
- } catch (error) {
24
- console.error(error)
25
- assertWarning(false, 'Error reading from localStorage')
26
- return codeLangDefaultClient
27
- }
28
- }
29
-
30
- const selectCodeLang = useCallback((value: string) => {
31
- try {
32
- window.localStorage.setItem(storageKey, value)
33
- setCodeLangSelected(value)
34
- window.dispatchEvent(new CustomEvent('code-lang-storage'))
35
- } catch (error) {
36
- console.error(error)
37
- assertWarning(false, 'Error setting localStorage')
38
- }
39
- }, [])
40
-
41
- useEffect(() => {
42
- // Initial load from localStorage
43
- updateState()
44
- // Update code lang in current tab
45
- window.addEventListener('code-lang-storage', updateState)
46
- // Update code lang if changed in another tab
47
- window.addEventListener('storage', updateStateOnStorageEvent)
48
- return () => {
49
- window.removeEventListener('code-lang-storage', updateState)
50
- window.removeEventListener('storage', updateStateOnStorageEvent)
51
- }
52
- }, [])
53
-
54
- return [codeLangSelected, selectCodeLang] as const
11
+ return useLocalStorage(storageKey, codeLangDefaultClient, codeLangDefaultSsr)
55
12
  }
56
13
 
57
14
  // WARNING: We cannot use the variables storageKey nor codeLangDefaultClient here: closures
@@ -66,9 +23,3 @@ function initializeJsToggle() {
66
23
  for (const input of inputs) input.checked = false
67
24
  }
68
25
  }
69
-
70
- declare global {
71
- interface WindowEventMap {
72
- 'code-lang-storage': CustomEvent
73
- }
74
- }
@@ -0,0 +1,34 @@
1
+ export { useSelectedChoice }
2
+ export { initializeChoiceGroup_SSR }
3
+
4
+ import { useState } from 'react'
5
+ import { useLocalStorage } from './useLocalStorage'
6
+
7
+ const keyPrefix = 'docpress'
8
+
9
+ /**
10
+ * Tracks the selected choice.
11
+ * Uses `useLocalStorage` if `persistId` is provided, otherwise regular state.
12
+ *
13
+ * @param persistId Optional ID to persist selection.
14
+ * @param defaultValue Default choice value.
15
+ * @returns `[selectedChoice, setSelectedChoice]`.
16
+ */
17
+ function useSelectedChoice(persistId: string | null, defaultValue: string) {
18
+ if (!persistId) return useState(defaultValue)
19
+
20
+ return useLocalStorage(`${keyPrefix}:${persistId}`, defaultValue)
21
+ }
22
+
23
+ // WARNING: We cannot use the keyPrefix variable here: closures don't work because we serialize the function.
24
+ const initializeChoiceGroup_SSR = `initializeChoiceGroup();${initializeChoiceGroup.toString()};`
25
+ function initializeChoiceGroup() {
26
+ const groupsElements = document.querySelectorAll<HTMLDivElement>('[data-group-name]')
27
+ for (const groupEl of groupsElements) {
28
+ const groupName = groupEl.getAttribute('data-group-name')!
29
+ const selectedChoice = localStorage.getItem(`docpress:${groupName}`)
30
+ if (!selectedChoice) continue
31
+ const selectEl = groupEl.querySelector<HTMLSelectElement>(`.select-choice`)
32
+ if (selectEl) selectEl.value = selectedChoice
33
+ }
34
+ }
@@ -23,41 +23,47 @@ function rehypeMetaToProps() {
23
23
  return (tree: Root) => {
24
24
  visit(tree, 'element', (node, _index, parent) => {
25
25
  if (node.tagName === 'code' && parent?.type === 'element' && parent.tagName === 'pre') {
26
- const props = parseMetaString(node.data?.meta)
26
+ const meta = parseMetaString(node.data?.meta)
27
27
  parent.properties ??= {}
28
- parent.properties = { ...parent.properties, ...props }
28
+ parent.properties = { ...parent.properties, ...meta.props }
29
29
  }
30
30
  })
31
31
  }
32
32
  }
33
33
 
34
34
  /**
35
- * Minimal parser for a metadata string into key-value pairs.
35
+ * Simple parser for a metadata string into key-value pairs and a remaining unparsed string.
36
36
  *
37
- * Supports simple patterns: key or key="value".
37
+ * Supports simple patterns: key or key=value.
38
38
  *
39
- * Keys must contain only letters, dashes, or underscores (no digits).
40
- * Keys are converted to kebab-case. Values default to "true" if missing.
39
+ * - Keys must contain only letters, dashes, or underscores (no digits).
40
+ * - Keys are converted to kebab-case. Values default to "true" if missing.
41
+ * - Keys and values are stored in `props`.
42
+ * - If `propNames` is provided, only keys included in that list are extracted.
43
+ * - Unextracted tokens remain in `rest`.
41
44
  *
42
- * Example:
43
- * parseMetaString('foo fooBar="value"')
44
- * => { foo: 'true', foo_bar: 'value' }
45
- *
46
- * @param metaString - The input metadata string.
47
- * @returns A plain object of parsed key-value pairs.
45
+ * @param meta - The input metadata string.
46
+ * @param propNames - Optional whitelist of property names to extract.
47
+ * @returns An object containing:
48
+ * - `props`: a map of extracted properties
49
+ * - `rest`: the remaining metadata string after extraction
48
50
  */
49
- function parseMetaString(metaString: ElementData['meta']): Record<string, string> {
50
- if (!metaString) return {}
51
+ function parseMetaString<Name extends string = string>(meta: ElementData['meta'], propNames?: Name[]): PropsType<Name> {
52
+ if (!meta) return { props: {}, rest: '' }
51
53
 
52
- const props: Record<string, string> = {}
54
+ let str = meta
53
55
 
54
- const keyValuePairRE = /([a-zA-Z_-]+)(?:=([^"'\s]+))?(?=\s|$)/g
55
- for (const match of metaString.matchAll(keyValuePairRE)) {
56
- let [_, key, value] = match
57
- props[kebabCase(key)] = value || 'true'
58
- }
56
+ const keyValuePairRE = /(?<name>[a-zA-Z_-]+)(?:=([^"'\s]+))?/g
57
+ const props: PropsType['props'] = {}
58
+
59
+ str = str.replaceAll(keyValuePairRE, (match, name, value) => {
60
+ if (propNames && !propNames.includes(name)) return match
59
61
 
60
- return props
62
+ props[kebabCase(name)] = value || 'true'
63
+ return ''
64
+ })
65
+
66
+ return { props, rest: str.trim() }
61
67
  }
62
68
 
63
69
  // Simple function to convert a camelCase or PascalCase string to kebab-case.
@@ -67,3 +73,8 @@ function kebabCase(str: string) {
67
73
  .replace('_', '-')
68
74
  .toLowerCase()
69
75
  }
76
+
77
+ interface PropsType<Name extends string = string> {
78
+ props: Partial<Record<Name, string>>
79
+ rest: string
80
+ }
@@ -0,0 +1,112 @@
1
+ export { remarkChoiceGroup }
2
+
3
+ import type { Code, Root } from 'mdast'
4
+ import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx'
5
+ import type { ContainerDirective } from 'mdast-util-directive'
6
+ import { visit } from 'unist-util-visit'
7
+ import { parseMetaString } from './rehypeMetaToProps.js'
8
+ import { generateChoiceGroup } from './utils/generateChoiceGroup.js'
9
+
10
+ type Node = Code | MdxJsxFlowElement | ContainerDirective
11
+
12
+ function remarkChoiceGroup() {
13
+ return function (tree: Root) {
14
+ visit(tree, (node) => {
15
+ if (node.type === 'code') {
16
+ if (!node.meta) return
17
+ const meta = parseMetaString(node.meta, ['choice'])
18
+ const { choice } = meta.props
19
+ node.meta = meta.rest
20
+
21
+ if (choice) node.data ??= { choice }
22
+ }
23
+ if (node.type === 'containerDirective' && node.name === 'Choice') {
24
+ if (!node.attributes) return
25
+ const { id: choice } = node.attributes
26
+ if (choice) {
27
+ node.data ??= { choice }
28
+ node.attributes = {}
29
+ }
30
+ }
31
+ })
32
+
33
+ const replaced = new WeakSet()
34
+ visit(tree, (node) => {
35
+ if (!('children' in node) || replaced.has(node)) return 'skip'
36
+ if (node.type === 'mdxJsxFlowElement') return 'skip'
37
+
38
+ let start = -1
39
+ let end = 0
40
+
41
+ const process = () => {
42
+ if (start === -1 || start === end) return
43
+ const nodes = node.children.slice(start, end) as Node[]
44
+ const groupedNodes = groupByNodeType(nodes)
45
+ const replacements: MdxJsxFlowElement[] = []
46
+
47
+ for (const groupedNode of groupedNodes) {
48
+ const replacement = generateChoiceGroup(groupedNode)
49
+
50
+ replacements.push(replacement)
51
+ replaced.add(replacement)
52
+ }
53
+
54
+ node.children.splice(start, end - start, ...replacements)
55
+
56
+ end = start
57
+ start = -1
58
+ }
59
+
60
+ for (; end < node.children.length; end++) {
61
+ const child = node.children[end]
62
+
63
+ if (!['code', 'mdxJsxFlowElement', 'containerDirective'].includes(child.type)) {
64
+ process()
65
+ continue
66
+ }
67
+
68
+ if (!child.data?.choice) {
69
+ process()
70
+ continue
71
+ }
72
+
73
+ if (start === -1) start = end
74
+ }
75
+
76
+ process()
77
+ })
78
+ }
79
+ }
80
+
81
+ type NodeGroup = {
82
+ value: string
83
+ children: Node[]
84
+ }
85
+
86
+ function groupByNodeType(nodes: Node[]) {
87
+ const groupedNodes = new Set<NodeGroup[]>()
88
+ const filters = [...new Set(nodes.flat().map((node) => (node.type === 'code' ? node.lang! : node.name)))]
89
+
90
+ filters.map((filter) => {
91
+ const nodesByChoice = new Map<string, Node[]>()
92
+ nodes
93
+ .filter((node) => (node.type === 'code' ? node.lang! : node.name) === filter)
94
+ .map((node) => {
95
+ const choice = node.data!.choice!
96
+ const nodes = nodesByChoice.get(choice) ?? []
97
+ nodes.push(node)
98
+ node.data = {}
99
+ nodesByChoice.set(choice, nodes)
100
+ })
101
+
102
+ groupedNodes.add([...nodesByChoice].map(([name, nodes]) => ({ value: name, children: nodes })))
103
+ })
104
+
105
+ return [...groupedNodes]
106
+ }
107
+
108
+ declare module 'mdast' {
109
+ export interface Data {
110
+ choice?: string
111
+ }
112
+ }
@@ -82,7 +82,11 @@ function transformYaml(node: CodeNode) {
82
82
 
83
83
  async function transformTsToJs(node: CodeNode, file: VFile) {
84
84
  const { codeBlock, index, parent } = node
85
- const maxWidth = Number(parseMetaString(codeBlock.meta)['max-width'])
85
+ const meta = parseMetaString(codeBlock.meta, ['max-width', 'choice'])
86
+ const maxWidth = Number(meta.props['max-width'])
87
+ const { choice } = meta.props
88
+ codeBlock.meta = meta.rest
89
+
86
90
  let codeBlockReplacedJs = replaceFileNameSuffixes(codeBlock.value)
87
91
  let codeBlockContentJs = ''
88
92
 
@@ -136,8 +140,8 @@ async function transformTsToJs(node: CodeNode, file: VFile) {
136
140
  // Add `hideToggle` attribute (prop) to `CodeSnippets` if the only change was replacing `.ts` with `.js`
137
141
  if (codeBlockReplacedJs === codeBlockContentJs) {
138
142
  attributes.push({
139
- name: 'hideToggle',
140
143
  type: 'mdxJsxAttribute',
144
+ name: 'hideToggle',
141
145
  })
142
146
  }
143
147
 
@@ -148,6 +152,9 @@ async function transformTsToJs(node: CodeNode, file: VFile) {
148
152
  children: [jsCode, codeBlock],
149
153
  attributes,
150
154
  }
155
+
156
+ if (choice) container.data ??= { choice }
157
+
151
158
  parent.children.splice(index, 1, container)
152
159
  }
153
160
 
@@ -0,0 +1,45 @@
1
+ export { remarkPkgManager }
2
+
3
+ import type { Code, Root } from 'mdast'
4
+ import { visit } from 'unist-util-visit'
5
+ import convert from 'npm-to-yarn'
6
+ import { parseMetaString } from './rehypeMetaToProps.js'
7
+ import { generateChoiceGroup } from './utils/generateChoiceGroup.js'
8
+
9
+ const PKG_MANAGERS = ['pnpm', 'yarn', 'bun'] as const
10
+
11
+ function remarkPkgManager() {
12
+ return function (tree: Root) {
13
+ visit(tree, 'code', (node, index, parent) => {
14
+ if (!parent || typeof index === 'undefined') return
15
+ if (!['sh', 'shell'].includes(node.lang || '')) return
16
+ if (node.value.indexOf('npm') === -1 && node.value.indexOf('npx') === -1) return
17
+
18
+ let choice: string | undefined = undefined
19
+ const nodes = new Map<string, Code>()
20
+
21
+ if (node.meta) {
22
+ const meta = parseMetaString(node.meta, ['choice'])
23
+ choice = meta.props['choice']
24
+ node.meta = meta.rest
25
+ }
26
+
27
+ nodes.set('npm', node)
28
+
29
+ for (const pm of PKG_MANAGERS) {
30
+ nodes.set(pm, {
31
+ type: node.type,
32
+ lang: node.lang,
33
+ meta: node.meta,
34
+ value: convert(node.value, pm),
35
+ })
36
+ }
37
+
38
+ const groupedNodes = [...nodes].map(([name, node]) => ({ value: name, children: [node] }))
39
+ const replacement = generateChoiceGroup(groupedNodes)
40
+
41
+ replacement.data ??= { choice }
42
+ parent.children.splice(index, 1, replacement)
43
+ })
44
+ }
45
+ }
@@ -0,0 +1,87 @@
1
+ export { generateChoiceGroup }
2
+ export type { CodeChoice }
3
+
4
+ import type { BlockContent, DefinitionContent } from 'mdast'
5
+ import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx-jsx'
6
+
7
+ type CodeChoice = {
8
+ value: string
9
+ children: (BlockContent | DefinitionContent)[]
10
+ }
11
+
12
+ function generateChoiceGroup(codeChoices: CodeChoice[]): MdxJsxFlowElement {
13
+ const attributes: MdxJsxAttribute[] = []
14
+ const children: MdxJsxFlowElement[] = []
15
+
16
+ attributes.push({
17
+ type: 'mdxJsxAttribute',
18
+ name: 'choices',
19
+ value: {
20
+ type: 'mdxJsxAttributeValueExpression',
21
+ value: '',
22
+ data: {
23
+ estree: {
24
+ type: 'Program',
25
+ sourceType: 'module',
26
+ comments: [],
27
+ body: [
28
+ {
29
+ type: 'ExpressionStatement',
30
+ expression: {
31
+ type: 'ArrayExpression',
32
+ // @ts-ignore: Missing properties in type definition
33
+ elements: codeChoices.map((choice) => ({
34
+ type: 'Literal',
35
+ value: choice.value,
36
+ })),
37
+ },
38
+ },
39
+ ],
40
+ },
41
+ },
42
+ },
43
+ })
44
+
45
+ for (const codeChoice of codeChoices) {
46
+ const classNames = ['choice']
47
+ if (findHasJsToggle(codeChoice.children[0])) {
48
+ classNames.push('has-toggle')
49
+ }
50
+
51
+ children.push({
52
+ type: 'mdxJsxFlowElement',
53
+ name: 'div',
54
+ attributes: [
55
+ { type: 'mdxJsxAttribute', name: 'id', value: codeChoice.value },
56
+ { type: 'mdxJsxAttribute', name: 'className', value: classNames.join(' ') },
57
+ ],
58
+ children: codeChoice.children.every((node) => node.type === 'containerDirective')
59
+ ? codeChoice.children.flatMap((node) => [...node.children])
60
+ : codeChoice.children,
61
+ })
62
+ }
63
+
64
+ return {
65
+ type: 'mdxJsxFlowElement',
66
+ name: 'ChoiceGroup',
67
+ attributes,
68
+ children,
69
+ }
70
+ }
71
+
72
+ function findHasJsToggle(node: BlockContent | DefinitionContent) {
73
+ if (node.type === 'containerDirective' && node.name === 'Choice') {
74
+ return (
75
+ node.children[0].type === 'mdxJsxFlowElement' &&
76
+ node.children[0].name === 'CodeSnippets' &&
77
+ node.children[0].attributes.every(
78
+ (attribute) => attribute.type !== 'mdxJsxAttribute' || attribute.name !== 'hideToggle',
79
+ )
80
+ )
81
+ }
82
+ return (
83
+ node.type === 'mdxJsxFlowElement' &&
84
+ node.name === 'CodeSnippets' &&
85
+ node.attributes.every((attribute) => attribute.type !== 'mdxJsxAttribute' || attribute.name !== 'hideToggle')
86
+ )
87
+ }
@@ -18,18 +18,24 @@ import type { ElementData, Root } from 'hast';
18
18
  */
19
19
  declare function rehypeMetaToProps(): (tree: Root) => void;
20
20
  /**
21
- * Minimal parser for a metadata string into key-value pairs.
21
+ * Simple parser for a metadata string into key-value pairs and a remaining unparsed string.
22
22
  *
23
- * Supports simple patterns: key or key="value".
23
+ * Supports simple patterns: key or key=value.
24
24
  *
25
- * Keys must contain only letters, dashes, or underscores (no digits).
26
- * Keys are converted to kebab-case. Values default to "true" if missing.
25
+ * - Keys must contain only letters, dashes, or underscores (no digits).
26
+ * - Keys are converted to kebab-case. Values default to "true" if missing.
27
+ * - Keys and values are stored in `props`.
28
+ * - If `propNames` is provided, only keys included in that list are extracted.
29
+ * - Unextracted tokens remain in `rest`.
27
30
  *
28
- * Example:
29
- * parseMetaString('foo fooBar="value"')
30
- * => { foo: 'true', foo_bar: 'value' }
31
- *
32
- * @param metaString - The input metadata string.
33
- * @returns A plain object of parsed key-value pairs.
31
+ * @param meta - The input metadata string.
32
+ * @param propNames - Optional whitelist of property names to extract.
33
+ * @returns An object containing:
34
+ * - `props`: a map of extracted properties
35
+ * - `rest`: the remaining metadata string after extraction
34
36
  */
35
- declare function parseMetaString(metaString: ElementData['meta']): Record<string, string>;
37
+ declare function parseMetaString<Name extends string = string>(meta: ElementData['meta'], propNames?: Name[]): PropsType<Name>;
38
+ interface PropsType<Name extends string = string> {
39
+ props: Partial<Record<Name, string>>;
40
+ rest: string;
41
+ }
@@ -20,38 +20,43 @@ function rehypeMetaToProps() {
20
20
  return (tree) => {
21
21
  visit(tree, 'element', (node, _index, parent) => {
22
22
  if (node.tagName === 'code' && parent?.type === 'element' && parent.tagName === 'pre') {
23
- const props = parseMetaString(node.data?.meta);
23
+ const meta = parseMetaString(node.data?.meta);
24
24
  parent.properties ??= {};
25
- parent.properties = { ...parent.properties, ...props };
25
+ parent.properties = { ...parent.properties, ...meta.props };
26
26
  }
27
27
  });
28
28
  };
29
29
  }
30
30
  /**
31
- * Minimal parser for a metadata string into key-value pairs.
31
+ * Simple parser for a metadata string into key-value pairs and a remaining unparsed string.
32
32
  *
33
- * Supports simple patterns: key or key="value".
33
+ * Supports simple patterns: key or key=value.
34
34
  *
35
- * Keys must contain only letters, dashes, or underscores (no digits).
36
- * Keys are converted to kebab-case. Values default to "true" if missing.
35
+ * - Keys must contain only letters, dashes, or underscores (no digits).
36
+ * - Keys are converted to kebab-case. Values default to "true" if missing.
37
+ * - Keys and values are stored in `props`.
38
+ * - If `propNames` is provided, only keys included in that list are extracted.
39
+ * - Unextracted tokens remain in `rest`.
37
40
  *
38
- * Example:
39
- * parseMetaString('foo fooBar="value"')
40
- * => { foo: 'true', foo_bar: 'value' }
41
- *
42
- * @param metaString - The input metadata string.
43
- * @returns A plain object of parsed key-value pairs.
41
+ * @param meta - The input metadata string.
42
+ * @param propNames - Optional whitelist of property names to extract.
43
+ * @returns An object containing:
44
+ * - `props`: a map of extracted properties
45
+ * - `rest`: the remaining metadata string after extraction
44
46
  */
45
- function parseMetaString(metaString) {
46
- if (!metaString)
47
- return {};
47
+ function parseMetaString(meta, propNames) {
48
+ if (!meta)
49
+ return { props: {}, rest: '' };
50
+ let str = meta;
51
+ const keyValuePairRE = /(?<name>[a-zA-Z_-]+)(?:=([^"'\s]+))?/g;
48
52
  const props = {};
49
- const keyValuePairRE = /([a-zA-Z_-]+)(?:=([^"'\s]+))?(?=\s|$)/g;
50
- for (const match of metaString.matchAll(keyValuePairRE)) {
51
- let [_, key, value] = match;
52
- props[kebabCase(key)] = value || 'true';
53
- }
54
- return props;
53
+ str = str.replaceAll(keyValuePairRE, (match, name, value) => {
54
+ if (propNames && !propNames.includes(name))
55
+ return match;
56
+ props[kebabCase(name)] = value || 'true';
57
+ return '';
58
+ });
59
+ return { props, rest: str.trim() };
55
60
  }
56
61
  // Simple function to convert a camelCase or PascalCase string to kebab-case.
57
62
  function kebabCase(str) {
@@ -0,0 +1,8 @@
1
+ export { remarkChoiceGroup };
2
+ import type { Root } from 'mdast';
3
+ declare function remarkChoiceGroup(): (tree: Root) => void;
4
+ declare module 'mdast' {
5
+ interface Data {
6
+ choice?: string;
7
+ }
8
+ }
@@ -0,0 +1,84 @@
1
+ export { remarkChoiceGroup };
2
+ import { visit } from 'unist-util-visit';
3
+ import { parseMetaString } from './rehypeMetaToProps.js';
4
+ import { generateChoiceGroup } from './utils/generateChoiceGroup.js';
5
+ function remarkChoiceGroup() {
6
+ return function (tree) {
7
+ visit(tree, (node) => {
8
+ if (node.type === 'code') {
9
+ if (!node.meta)
10
+ return;
11
+ const meta = parseMetaString(node.meta, ['choice']);
12
+ const { choice } = meta.props;
13
+ node.meta = meta.rest;
14
+ if (choice)
15
+ node.data ??= { choice };
16
+ }
17
+ if (node.type === 'containerDirective' && node.name === 'Choice') {
18
+ if (!node.attributes)
19
+ return;
20
+ const { id: choice } = node.attributes;
21
+ if (choice) {
22
+ node.data ??= { choice };
23
+ node.attributes = {};
24
+ }
25
+ }
26
+ });
27
+ const replaced = new WeakSet();
28
+ visit(tree, (node) => {
29
+ if (!('children' in node) || replaced.has(node))
30
+ return 'skip';
31
+ if (node.type === 'mdxJsxFlowElement')
32
+ return 'skip';
33
+ let start = -1;
34
+ let end = 0;
35
+ const process = () => {
36
+ if (start === -1 || start === end)
37
+ return;
38
+ const nodes = node.children.slice(start, end);
39
+ const groupedNodes = groupByNodeType(nodes);
40
+ const replacements = [];
41
+ for (const groupedNode of groupedNodes) {
42
+ const replacement = generateChoiceGroup(groupedNode);
43
+ replacements.push(replacement);
44
+ replaced.add(replacement);
45
+ }
46
+ node.children.splice(start, end - start, ...replacements);
47
+ end = start;
48
+ start = -1;
49
+ };
50
+ for (; end < node.children.length; end++) {
51
+ const child = node.children[end];
52
+ if (!['code', 'mdxJsxFlowElement', 'containerDirective'].includes(child.type)) {
53
+ process();
54
+ continue;
55
+ }
56
+ if (!child.data?.choice) {
57
+ process();
58
+ continue;
59
+ }
60
+ if (start === -1)
61
+ start = end;
62
+ }
63
+ process();
64
+ });
65
+ };
66
+ }
67
+ function groupByNodeType(nodes) {
68
+ const groupedNodes = new Set();
69
+ const filters = [...new Set(nodes.flat().map((node) => (node.type === 'code' ? node.lang : node.name)))];
70
+ filters.map((filter) => {
71
+ const nodesByChoice = new Map();
72
+ nodes
73
+ .filter((node) => (node.type === 'code' ? node.lang : node.name) === filter)
74
+ .map((node) => {
75
+ const choice = node.data.choice;
76
+ const nodes = nodesByChoice.get(choice) ?? [];
77
+ nodes.push(node);
78
+ node.data = {};
79
+ nodesByChoice.set(choice, nodes);
80
+ });
81
+ groupedNodes.add([...nodesByChoice].map(([name, nodes]) => ({ value: name, children: nodes })));
82
+ });
83
+ return [...groupedNodes];
84
+ }
@@ -65,7 +65,10 @@ function transformYaml(node) {
65
65
  }
66
66
  async function transformTsToJs(node, file) {
67
67
  const { codeBlock, index, parent } = node;
68
- const maxWidth = Number(parseMetaString(codeBlock.meta)['max-width']);
68
+ const meta = parseMetaString(codeBlock.meta, ['max-width', 'choice']);
69
+ const maxWidth = Number(meta.props['max-width']);
70
+ const { choice } = meta.props;
71
+ codeBlock.meta = meta.rest;
69
72
  let codeBlockReplacedJs = replaceFileNameSuffixes(codeBlock.value);
70
73
  let codeBlockContentJs = '';
71
74
  // Remove TypeScript from the TS/TSX/Vue code node
@@ -113,8 +116,8 @@ async function transformTsToJs(node, file) {
113
116
  // Add `hideToggle` attribute (prop) to `CodeSnippets` if the only change was replacing `.ts` with `.js`
114
117
  if (codeBlockReplacedJs === codeBlockContentJs) {
115
118
  attributes.push({
116
- name: 'hideToggle',
117
119
  type: 'mdxJsxAttribute',
120
+ name: 'hideToggle',
118
121
  });
119
122
  }
120
123
  // Wrap both the original `codeBlock` and `jsCode` with <CodeSnippets>
@@ -124,6 +127,8 @@ async function transformTsToJs(node, file) {
124
127
  children: [jsCode, codeBlock],
125
128
  attributes,
126
129
  };
130
+ if (choice)
131
+ container.data ??= { choice };
127
132
  parent.children.splice(index, 1, container);
128
133
  }
129
134
  // Replace all '.ts' extensions with '.js'
@@ -0,0 +1,3 @@
1
+ export { remarkPkgManager };
2
+ import type { Root } from 'mdast';
3
+ declare function remarkPkgManager(): (tree: Root) => void;
@@ -0,0 +1,38 @@
1
+ export { remarkPkgManager };
2
+ import { visit } from 'unist-util-visit';
3
+ import convert from 'npm-to-yarn';
4
+ import { parseMetaString } from './rehypeMetaToProps.js';
5
+ import { generateChoiceGroup } from './utils/generateChoiceGroup.js';
6
+ const PKG_MANAGERS = ['pnpm', 'yarn', 'bun'];
7
+ function remarkPkgManager() {
8
+ return function (tree) {
9
+ visit(tree, 'code', (node, index, parent) => {
10
+ if (!parent || typeof index === 'undefined')
11
+ return;
12
+ if (!['sh', 'shell'].includes(node.lang || ''))
13
+ return;
14
+ if (node.value.indexOf('npm') === -1 && node.value.indexOf('npx') === -1)
15
+ return;
16
+ let choice = undefined;
17
+ const nodes = new Map();
18
+ if (node.meta) {
19
+ const meta = parseMetaString(node.meta, ['choice']);
20
+ choice = meta.props['choice'];
21
+ node.meta = meta.rest;
22
+ }
23
+ nodes.set('npm', node);
24
+ for (const pm of PKG_MANAGERS) {
25
+ nodes.set(pm, {
26
+ type: node.type,
27
+ lang: node.lang,
28
+ meta: node.meta,
29
+ value: convert(node.value, pm),
30
+ });
31
+ }
32
+ const groupedNodes = [...nodes].map(([name, node]) => ({ value: name, children: [node] }));
33
+ const replacement = generateChoiceGroup(groupedNodes);
34
+ replacement.data ??= { choice };
35
+ parent.children.splice(index, 1, replacement);
36
+ });
37
+ };
38
+ }
@@ -0,0 +1,9 @@
1
+ export { generateChoiceGroup };
2
+ export type { CodeChoice };
3
+ import type { BlockContent, DefinitionContent } from 'mdast';
4
+ import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
5
+ type CodeChoice = {
6
+ value: string;
7
+ children: (BlockContent | DefinitionContent)[];
8
+ };
9
+ declare function generateChoiceGroup(codeChoices: CodeChoice[]): MdxJsxFlowElement;
@@ -0,0 +1,66 @@
1
+ export { generateChoiceGroup };
2
+ function generateChoiceGroup(codeChoices) {
3
+ const attributes = [];
4
+ const children = [];
5
+ attributes.push({
6
+ type: 'mdxJsxAttribute',
7
+ name: 'choices',
8
+ value: {
9
+ type: 'mdxJsxAttributeValueExpression',
10
+ value: '',
11
+ data: {
12
+ estree: {
13
+ type: 'Program',
14
+ sourceType: 'module',
15
+ comments: [],
16
+ body: [
17
+ {
18
+ type: 'ExpressionStatement',
19
+ expression: {
20
+ type: 'ArrayExpression',
21
+ // @ts-ignore: Missing properties in type definition
22
+ elements: codeChoices.map((choice) => ({
23
+ type: 'Literal',
24
+ value: choice.value,
25
+ })),
26
+ },
27
+ },
28
+ ],
29
+ },
30
+ },
31
+ },
32
+ });
33
+ for (const codeChoice of codeChoices) {
34
+ const classNames = ['choice'];
35
+ if (findHasJsToggle(codeChoice.children[0])) {
36
+ classNames.push('has-toggle');
37
+ }
38
+ children.push({
39
+ type: 'mdxJsxFlowElement',
40
+ name: 'div',
41
+ attributes: [
42
+ { type: 'mdxJsxAttribute', name: 'id', value: codeChoice.value },
43
+ { type: 'mdxJsxAttribute', name: 'className', value: classNames.join(' ') },
44
+ ],
45
+ children: codeChoice.children.every((node) => node.type === 'containerDirective')
46
+ ? codeChoice.children.flatMap((node) => [...node.children])
47
+ : codeChoice.children,
48
+ });
49
+ }
50
+ return {
51
+ type: 'mdxJsxFlowElement',
52
+ name: 'ChoiceGroup',
53
+ attributes,
54
+ children,
55
+ };
56
+ }
57
+ function findHasJsToggle(node) {
58
+ if (node.type === 'containerDirective' && node.name === 'Choice') {
59
+ return (node.children[0].type === 'mdxJsxFlowElement' &&
60
+ node.children[0].name === 'CodeSnippets' &&
61
+ node.children[0].attributes.every((attribute) => attribute.type !== 'mdxJsxAttribute' || attribute.name !== 'hideToggle'));
62
+ }
63
+ return (node.type === 'mdxJsxFlowElement' &&
64
+ node.name === 'CodeSnippets' &&
65
+ node.attributes.every((attribute) => attribute.type !== 'mdxJsxAttribute' || attribute.name !== 'hideToggle'));
66
+ }
@@ -37,6 +37,7 @@ type Config = {
37
37
  navLogoStyle?: React.CSSProperties;
38
38
  navLogoTextStyle?: React.CSSProperties;
39
39
  globalNote?: React.ReactNode;
40
+ choices?: Record<string, Choice>;
40
41
  };
41
42
  /** Order in Algolia search results */
42
43
  type Category = string | {
@@ -44,3 +45,7 @@ type Category = string | {
44
45
  /** Hide from Algolia search */
45
46
  hide?: boolean;
46
47
  };
48
+ type Choice = {
49
+ choices: string[];
50
+ default: string;
51
+ };
@@ -4,10 +4,13 @@ import react from '@vitejs/plugin-react-swc';
4
4
  import { parsePageSections } from './parsePageSections.js';
5
5
  import rehypePrettyCode from 'rehype-pretty-code';
6
6
  import remarkGfm from 'remark-gfm';
7
+ import remarkDirective from 'remark-directive';
7
8
  import { transformerNotationDiff } from '@shikijs/transformers';
8
9
  import { rehypeMetaToProps } from './code-blocks/rehypeMetaToProps.js';
9
10
  import { remarkDetype } from './code-blocks/remarkDetype.js';
10
11
  import { shikiTransformerAutoLinks } from './code-blocks/shikiTransformerAutoLinks.js';
12
+ import { remarkPkgManager } from './code-blocks/remarkPkgManager.js';
13
+ import { remarkChoiceGroup } from './code-blocks/remarkChoiceGroup.js';
11
14
  const root = process.cwd();
12
15
  const prettyCode = [
13
16
  rehypePrettyCode,
@@ -18,7 +21,7 @@ const prettyCode = [
18
21
  },
19
22
  ];
20
23
  const rehypePlugins = [prettyCode, [rehypeMetaToProps]];
21
- const remarkPlugins = [remarkGfm, remarkDetype];
24
+ const remarkPlugins = [remarkGfm, remarkDirective, remarkDetype, remarkPkgManager, remarkChoiceGroup];
22
25
  const config = {
23
26
  root,
24
27
  plugins: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brillout/docpress",
3
- "version": "0.16.7",
3
+ "version": "0.16.8",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@brillout/picocolors": "^1.0.10",
@@ -12,7 +12,9 @@
12
12
  "@shikijs/transformers": "1.2.0",
13
13
  "@vitejs/plugin-react-swc": "^3.10.2",
14
14
  "detype": "^1.1.3",
15
+ "npm-to-yarn": "^3.0.1",
15
16
  "rehype-pretty-code": "0.13.0",
17
+ "remark-directive": "^4.0.0",
16
18
  "remark-gfm": "4.0.0",
17
19
  "shiki": "1.2.0",
18
20
  "unist-util-visit": "^5.0.0",
@@ -65,6 +67,7 @@
65
67
  "@types/node": "^24.10.0",
66
68
  "@types/react": "^19.2.2",
67
69
  "@types/react-dom": "^19.2.2",
70
+ "mdast-util-directive": "^3.1.0",
68
71
  "mdast-util-mdx-jsx": "^3.2.0"
69
72
  },
70
73
  "repository": "https://github.com/brillout/docpress",
@@ -99,6 +99,7 @@ function getOpenGraphTags(url: string, documentTitle: string, config: Config) {
99
99
  // Resources:
100
100
  // - https://www.google.com/s2/favicons?domain=vike.dev
101
101
  // - https://stackoverflow.com/questions/59568586/favicon-don%c2%b4t-show-up-in-google-search-result/59577456#59577456
102
+ // - https://stackoverflow.com/questions/76123025/attach-google-search-specific-favicon-to-html
102
103
  // - https://developers.google.com/search/docs/appearance/favicon-in-search
103
104
  //
104
105
  // Examples:
@@ -108,8 +109,10 @@ function getOpenGraphTags(url: string, documentTitle: string, config: Config) {
108
109
  // https://rubyonrails.org
109
110
  // - Favicon shown in browser is different than favicon shown in Google:
110
111
  // https://evilmartians.com
111
- // Shown in Google: <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
112
+ // Shown in Google: <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> or <link rel="manifest" href="/manifest.webmanifest" />
112
113
  // Shown in Browser: <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
114
+ // https://gemini.google.com/share/8bcf76a26783
115
+ // https://chatgpt.com/share/696930b6-4414-800d-9c55-598ac7fa1ccb
113
116
  function getFaviconTags(config: Config) {
114
117
  const { faviconBrowser, faviconGoogle } = getFavicons(config)
115
118
  assert(faviconBrowser)
package/types/Config.ts CHANGED
@@ -48,6 +48,7 @@ type Config = {
48
48
  navLogoTextStyle?: React.CSSProperties
49
49
 
50
50
  globalNote?: React.ReactNode
51
+ choices?: Record<string, Choice>
51
52
  }
52
53
 
53
54
  /** Order in Algolia search results */
@@ -58,3 +59,8 @@ type Category =
58
59
  /** Hide from Algolia search */
59
60
  hide?: boolean
60
61
  }
62
+
63
+ type Choice = {
64
+ choices: string[]
65
+ default: string
66
+ }
package/vite.config.ts CHANGED
@@ -6,10 +6,13 @@ import type { PluginOption, UserConfig } from 'vite'
6
6
  import { parsePageSections } from './parsePageSections.js'
7
7
  import rehypePrettyCode from 'rehype-pretty-code'
8
8
  import remarkGfm from 'remark-gfm'
9
+ import remarkDirective from 'remark-directive'
9
10
  import { transformerNotationDiff } from '@shikijs/transformers'
10
11
  import { rehypeMetaToProps } from './code-blocks/rehypeMetaToProps.js'
11
12
  import { remarkDetype } from './code-blocks/remarkDetype.js'
12
13
  import { shikiTransformerAutoLinks } from './code-blocks/shikiTransformerAutoLinks.js'
14
+ import { remarkPkgManager } from './code-blocks/remarkPkgManager.js'
15
+ import { remarkChoiceGroup } from './code-blocks/remarkChoiceGroup.js'
13
16
 
14
17
  const root = process.cwd()
15
18
  const prettyCode = [
@@ -21,7 +24,7 @@ const prettyCode = [
21
24
  },
22
25
  ]
23
26
  const rehypePlugins: any = [prettyCode, [rehypeMetaToProps]]
24
- const remarkPlugins = [remarkGfm, remarkDetype]
27
+ const remarkPlugins = [remarkGfm, remarkDirective, remarkDetype, remarkPkgManager, remarkChoiceGroup]
25
28
 
26
29
  const config: UserConfig = {
27
30
  root,