@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 +6 -2
- package/code-blocks/components/ChoiceGroup.css +48 -0
- package/code-blocks/components/ChoiceGroup.tsx +85 -0
- package/code-blocks/components/CodeSnippets.tsx +3 -13
- package/code-blocks/components/Pre.tsx +27 -3
- package/code-blocks/hooks/useLocalStorage.ts +39 -0
- package/code-blocks/hooks/useMDXComponents.tsx +2 -0
- package/code-blocks/hooks/useRestoreScroll.ts +31 -0
- package/code-blocks/hooks/useSelectCodeLang.ts +2 -51
- package/code-blocks/hooks/useSelectedChoice.ts +34 -0
- package/code-blocks/rehypeMetaToProps.ts +32 -21
- package/code-blocks/remarkChoiceGroup.ts +112 -0
- package/code-blocks/remarkDetype.ts +9 -2
- package/code-blocks/remarkPkgManager.ts +45 -0
- package/code-blocks/utils/generateChoiceGroup.ts +87 -0
- package/dist/code-blocks/rehypeMetaToProps.d.ts +17 -11
- package/dist/code-blocks/rehypeMetaToProps.js +26 -21
- package/dist/code-blocks/remarkChoiceGroup.d.ts +8 -0
- package/dist/code-blocks/remarkChoiceGroup.js +84 -0
- package/dist/code-blocks/remarkDetype.js +7 -2
- package/dist/code-blocks/remarkPkgManager.d.ts +3 -0
- package/dist/code-blocks/remarkPkgManager.js +38 -0
- package/dist/code-blocks/utils/generateChoiceGroup.d.ts +9 -0
- package/dist/code-blocks/utils/generateChoiceGroup.js +66 -0
- package/dist/types/Config.d.ts +5 -0
- package/dist/vite.config.js +4 -1
- package/package.json +4 -1
- package/renderer/onRenderHtml.tsx +4 -1
- package/types/Config.ts +6 -0
- package/vite.config.ts +4 -1
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
|
|
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 =
|
|
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, {
|
|
3
|
+
import React, { useState } from 'react'
|
|
4
|
+
import { cls } from '../../utils/cls'
|
|
4
5
|
import './Pre.css'
|
|
5
6
|
|
|
6
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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=
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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(
|
|
50
|
-
if (!
|
|
51
|
+
function parseMetaString<Name extends string = string>(meta: ElementData['meta'], propNames?: Name[]): PropsType<Name> {
|
|
52
|
+
if (!meta) return { props: {}, rest: '' }
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
let str = meta
|
|
53
55
|
|
|
54
|
-
const keyValuePairRE = /([a-zA-Z_-]+)(?:=([^"'\s]+))
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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=
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
*
|
|
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=
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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(
|
|
46
|
-
if (!
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
props[kebabCase(
|
|
53
|
-
|
|
54
|
-
|
|
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,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
|
|
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,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
|
+
}
|
package/dist/types/Config.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/vite.config.js
CHANGED
|
@@ -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.
|
|
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,
|