@brillout/docpress 0.15.10 → 0.15.11-commit-e615832

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.
Files changed (48) hide show
  1. package/Layout.tsx +3 -0
  2. package/autoScrollNav.ts +3 -3
  3. package/code-blocks/components/CodeSnippets.css +74 -0
  4. package/code-blocks/components/CodeSnippets.tsx +51 -0
  5. package/code-blocks/components/Pre.css +51 -0
  6. package/code-blocks/components/Pre.tsx +70 -0
  7. package/code-blocks/hooks/useMDXComponents.tsx +13 -0
  8. package/code-blocks/hooks/useSelectCodeLang.ts +74 -0
  9. package/code-blocks/rehypeMetaToProps.ts +69 -0
  10. package/code-blocks/remarkDetype.ts +192 -0
  11. package/code-blocks/shikiTransformerAutoLinks.ts +61 -0
  12. package/css/button.css +23 -0
  13. package/css/code.css +3 -21
  14. package/css/tooltip.css +10 -2
  15. package/dist/+config.js +1 -1
  16. package/dist/NavItemComponent.js +38 -46
  17. package/dist/code-blocks/rehypeMetaToProps.d.ts +35 -0
  18. package/dist/code-blocks/rehypeMetaToProps.js +62 -0
  19. package/dist/code-blocks/remarkDetype.d.ts +4 -0
  20. package/dist/code-blocks/remarkDetype.js +164 -0
  21. package/dist/code-blocks/shikiTransformerAutoLinks.d.ts +8 -0
  22. package/dist/code-blocks/shikiTransformerAutoLinks.js +51 -0
  23. package/dist/components/CodeBlockTransformer.js +2 -3
  24. package/dist/components/Comment.js +1 -2
  25. package/dist/components/FileRemoved.js +4 -6
  26. package/dist/components/HorizontalLine.js +1 -2
  27. package/dist/components/ImportMeta.js +2 -3
  28. package/dist/components/Link.js +34 -50
  29. package/dist/components/Note.js +17 -29
  30. package/dist/components/P.js +1 -12
  31. package/dist/components/RepoLink.js +7 -9
  32. package/dist/determineNavItemsColumnLayout.js +48 -63
  33. package/dist/parseMarkdownMini.js +5 -17
  34. package/dist/parsePageSections.js +41 -82
  35. package/dist/renderer/usePageContext.js +6 -7
  36. package/dist/resolvePageContext.js +103 -110
  37. package/dist/utils/Emoji/Emoji.js +13 -21
  38. package/dist/utils/assert.js +14 -16
  39. package/dist/utils/cls.js +1 -1
  40. package/dist/utils/determineSectionUrlHash.js +5 -5
  41. package/dist/utils/filter.js +2 -2
  42. package/dist/utils/getGlobalObject.js +3 -3
  43. package/dist/vite.config.js +17 -7
  44. package/index.ts +16 -14
  45. package/package.json +7 -2
  46. package/resolvePageContext.ts +19 -15
  47. package/tsconfig.json +2 -1
  48. package/vite.config.ts +14 -4
package/Layout.tsx CHANGED
@@ -15,6 +15,7 @@ import { ExternalLinks } from './ExternalLinks'
15
15
  import { coseMenuModalOnMouseLeave, openMenuModal, toggleMenuModal } from './MenuModal/toggleMenuModal'
16
16
  import { MenuModal } from './MenuModal'
17
17
  import { autoScrollNav_SSR } from './autoScrollNav'
18
+ import { initializeJsToggle_SSR } from './code-blocks/hooks/useSelectCodeLang'
18
19
  import { SearchLink } from './docsearch/SearchLink'
19
20
  import { navigate } from 'vike/client/router'
20
21
  import { css } from './utils/css'
@@ -78,6 +79,8 @@ function Layout({ children }: { children: React.ReactNode }) {
78
79
  <NavHead />
79
80
  {content}
80
81
  </div>
82
+ {/* Early toggling, to avoid layout jumps */}
83
+ <script dangerouslySetInnerHTML={{ __html: initializeJsToggle_SSR }}></script>
81
84
  </div>
82
85
  )
83
86
  }
package/autoScrollNav.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { autoScrollNav }
2
- export const autoScrollNav_SSR = `autoScrollNav();${autoScrollNav.toString()}`
2
+ export { autoScrollNav_SSR }
3
3
 
4
- // - We cannot use TypeScript syntax because of autoScrollNav_SSR
4
+ // - WARNING: We cannot use TypeScript here because we serialize the function.
5
5
  // - We have to save & restore `document.documentElement.scrollTop` because scrollIntoView() scrolls the main view. (I don't know why).
6
6
  // - Failed alternatives:
7
7
  // - scrollIntoViewIfNeeded() (https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded) would work (it doesn't scroll the main view) but Firefox doesn't support it.
@@ -17,7 +17,7 @@ export const autoScrollNav_SSR = `autoScrollNav();${autoScrollNav.toString()}`
17
17
  // // ...
18
18
  // document.documentElement.style.overflow = overflowOriginal
19
19
  // ```
20
-
20
+ const autoScrollNav_SSR = `autoScrollNav();${autoScrollNav.toString()}`
21
21
  function autoScrollNav() {
22
22
  const nav = document.querySelector('#nav-left .navigation-content')
23
23
  if (!nav) return
@@ -0,0 +1,74 @@
1
+ .code-snippets {
2
+ position: relative;
3
+
4
+ &:hover {
5
+ .copy-button,
6
+ .code-lang-toggle {
7
+ opacity: 1;
8
+ }
9
+ }
10
+
11
+ /* Language toggle styles */
12
+ .code-lang-toggle {
13
+ position: absolute !important;
14
+ top: 10px;
15
+ right: 42px;
16
+ z-index: 3;
17
+
18
+ /* Checkbox appearance reset */
19
+ appearance: none;
20
+ -webkit-appearance: none;
21
+ -moz-appearance: none;
22
+
23
+ margin: 0;
24
+ padding: 0 4px;
25
+ height: 25px;
26
+ width: 59px;
27
+ display: flex;
28
+ background-color: #f7f7f7;
29
+ opacity: 0;
30
+ transition: opacity 0.5s ease-in-out, background-color 0.4s ease-in-out;
31
+
32
+ &:not(:hover) {
33
+ background-color: #eee;
34
+ }
35
+
36
+ /* Toggle Labels */
37
+ &::before,
38
+ &::after {
39
+ width: 24px;
40
+ display: flex;
41
+ justify-content: center;
42
+ align-items: center;
43
+ }
44
+
45
+ &::before {
46
+ content: 'JS';
47
+ }
48
+
49
+ &::after {
50
+ content: 'TS';
51
+ border-left: none;
52
+ opacity: 0.3;
53
+ }
54
+
55
+ &:checked {
56
+ &::before {
57
+ opacity: 0.3;
58
+ }
59
+
60
+ &::after {
61
+ opacity: 1;
62
+ }
63
+ }
64
+ }
65
+
66
+ /* Code block visibility based on toggle */
67
+ &:has(.code-lang-toggle:checked) figure:first-of-type {
68
+ display: none;
69
+ }
70
+
71
+ &:has(.code-lang-toggle:not(:checked)) figure:last-of-type {
72
+ display: none;
73
+ }
74
+ }
@@ -0,0 +1,51 @@
1
+ // Public
2
+ export { TypescriptOnly }
3
+
4
+ // Internal
5
+ export { CodeSnippets }
6
+
7
+ import React, { useEffect, useRef } from 'react'
8
+ import { useSelectCodeLang } from '../hooks/useSelectCodeLang'
9
+ import './CodeSnippets.css'
10
+
11
+ /** Only show if TypeScript is selected */
12
+ function TypescriptOnly({ children }: { children: React.ReactNode }) {
13
+ const [codeLangSelected] = useSelectCodeLang()
14
+ return <div style={{ display: codeLangSelected === 'ts' ? 'block' : 'none' }}>{children}</div>
15
+ }
16
+
17
+ function CodeSnippets({ children, hideToggle = false }: { children: React.ReactNode; hideToggle: boolean }) {
18
+ 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])
31
+
32
+ return (
33
+ <div className="code-snippets">
34
+ <input
35
+ type="checkbox"
36
+ name="code-lang-toggle"
37
+ className="code-lang-toggle raised"
38
+ style={{ display: hideToggle ? 'none' : undefined }}
39
+ checked={codeLangSelected === 'ts'}
40
+ onChange={onChange}
41
+ title="Toggle language"
42
+ />
43
+ {children}
44
+ </div>
45
+ )
46
+ function onChange(e: React.ChangeEvent<HTMLInputElement>) {
47
+ const element = e.target
48
+ prevPositionRef.current = { top: element.getBoundingClientRect().top, el: element }
49
+ selectCodeLang(element.checked ? 'ts' : 'js')
50
+ }
51
+ }
@@ -0,0 +1,51 @@
1
+ pre > code {
2
+ /*
3
+ background-color: #f4f4f4;
4
+ 0.043137255 = 1 - (#f4 / #ff)
5
+ */
6
+ background: rgba(0, 0, 0, 0.043137255);
7
+ font-size: 1em;
8
+ }
9
+
10
+ /* Copy button */
11
+ pre {
12
+ &:has(.copy-button) {
13
+ position: relative;
14
+ }
15
+
16
+ &:hover {
17
+ .copy-button {
18
+ opacity: 1;
19
+ }
20
+ }
21
+
22
+ .copy-button {
23
+ position: absolute !important;
24
+ top: 10px;
25
+ right: 10px;
26
+ z-index: 3;
27
+ margin: 0;
28
+ height: 25px;
29
+ width: 30px;
30
+ background-color: #f7f7f7;
31
+ opacity: 0;
32
+ transition: opacity 0.5s ease-in-out, background-color 0.4s ease-in-out;
33
+
34
+ &:not(:hover) {
35
+ background-color: #eee;
36
+ }
37
+
38
+ & svg {
39
+ width: 100%;
40
+ height: 100%;
41
+ fill: none;
42
+ stroke-linecap: round;
43
+ stroke-linejoin: round;
44
+ }
45
+ }
46
+ }
47
+
48
+ /* Workaround for shiki regression */
49
+ pre > code:not([data-language]) {
50
+ padding: 16px !important;
51
+ }
@@ -0,0 +1,70 @@
1
+ export { Pre }
2
+
3
+ import React, { ComponentPropsWithoutRef, useState } from 'react'
4
+ import './Pre.css'
5
+
6
+ function Pre({ children, ...props }: ComponentPropsWithoutRef<'pre'> & { 'hide-menu'?: string }) {
7
+ return (
8
+ <pre {...props}>
9
+ {!props['hide-menu'] && <CopyButton />}
10
+ {children}
11
+ </pre>
12
+ )
13
+ }
14
+
15
+ function CopyButton() {
16
+ const [isSuccess, setIsSuccess] = useState(null as null | boolean)
17
+ const onCopy = (success: boolean) => {
18
+ setIsSuccess(success)
19
+ setTimeout(() => {
20
+ setIsSuccess(null)
21
+ }, 900)
22
+ }
23
+ const tooltip = isSuccess === null ? 'Copy to clipboard' : isSuccess ? 'Copied' : 'Failed'
24
+ const icon =
25
+ isSuccess === null ? (
26
+ // Copy icon
27
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="black" strokeWidth="2">
28
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
29
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
30
+ </svg>
31
+ ) : isSuccess ? (
32
+ // Green checkmark
33
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="#28a745" strokeWidth="3">
34
+ <polyline points="20 6 9 17 4 12" />
35
+ </svg>
36
+ ) : (
37
+ '❌'
38
+ )
39
+ return (
40
+ <button
41
+ className="copy-button raised"
42
+ aria-label={tooltip}
43
+ data-label-position="top"
44
+ type="button"
45
+ onClick={onClick}
46
+ >
47
+ {icon}
48
+ </button>
49
+ )
50
+ async function onClick(e: React.MouseEvent<HTMLButtonElement>) {
51
+ let success: boolean
52
+ const preEl = e.currentTarget.parentElement!
53
+ let text = preEl.textContent || ''
54
+ text = removeTrailingWhitespaces(text)
55
+ try {
56
+ await navigator.clipboard.writeText(text)
57
+ success = true
58
+ } catch (error) {
59
+ console.error(error)
60
+ success = false
61
+ }
62
+ onCopy(success)
63
+ }
64
+ }
65
+ function removeTrailingWhitespaces(text: string) {
66
+ return text
67
+ .split('\n')
68
+ .map((line) => line.trimEnd())
69
+ .join('\n')
70
+ }
@@ -0,0 +1,13 @@
1
+ export { useMDXComponents }
2
+
3
+ import React from 'react'
4
+ import type { UseMdxComponents } from '@mdx-js/mdx'
5
+ import { Pre } from '../components/Pre.js'
6
+ import { CodeSnippets } from '../components/CodeSnippets.js'
7
+
8
+ const useMDXComponents: UseMdxComponents = () => {
9
+ return {
10
+ CodeSnippets,
11
+ pre: (props) => <Pre {...props} />,
12
+ }
13
+ }
@@ -0,0 +1,74 @@
1
+ export { useSelectCodeLang }
2
+ export { initializeJsToggle_SSR }
3
+
4
+ import { useState, useEffect, useCallback } from 'react'
5
+ import { assertWarning } from '../../utils/assert'
6
+
7
+ const storageKey = 'docpress:code-lang'
8
+ const codeLangDefaultSsr = 'ts'
9
+ const codeLangDefaultClient = 'js'
10
+
11
+ 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
55
+ }
56
+
57
+ // WARNING: We cannot use the variables storageKey nor codeLangDefaultClient here: closures
58
+ // don't work because we serialize the function.
59
+ // WARNING: We cannot use TypeScript here, for the same reason.
60
+ const initializeJsToggle_SSR = `initializeJsToggle();${initializeJsToggle.toString()};`
61
+ function initializeJsToggle() {
62
+ const codeLangSelected = localStorage.getItem('docpress:code-lang') ?? 'js'
63
+ if (codeLangSelected === 'js') {
64
+ const inputs = document.querySelectorAll('.code-lang-toggle')
65
+ // @ts-ignore
66
+ for (const input of inputs) input.checked = false
67
+ }
68
+ }
69
+
70
+ declare global {
71
+ interface WindowEventMap {
72
+ 'code-lang-storage': CustomEvent
73
+ }
74
+ }
@@ -0,0 +1,69 @@
1
+ export { rehypeMetaToProps, parseMetaString }
2
+
3
+ import { visit } from 'unist-util-visit'
4
+ import type { ElementData, Root } from 'hast'
5
+
6
+ /**
7
+ * Rehype plugin to extract metadata from `<code>` blocks in markdown
8
+ * and attach them as props to the parent `<pre>` element.
9
+ *
10
+ * This allows using those props inside a custom `<Pre>` component.
11
+ *
12
+ * Example:
13
+ * ~~~mdx
14
+ * ```js foo="bar" hide_copy='true'
15
+ * export function add(a, b) {
16
+ * return a + b
17
+ * }
18
+ * ```
19
+ * ~~~
20
+ * These props are then added to the `<pre>` element
21
+ */
22
+ function rehypeMetaToProps() {
23
+ return (tree: Root) => {
24
+ visit(tree, 'element', (node, _index, parent) => {
25
+ if (node.tagName === 'code' && parent?.type === 'element' && parent.tagName === 'pre') {
26
+ const props = parseMetaString(node.data?.meta)
27
+ parent.properties ??= {}
28
+ parent.properties = { ...parent.properties, ...props }
29
+ }
30
+ })
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Minimal parser for a metadata string into key-value pairs.
36
+ *
37
+ * Supports simple patterns: key or key="value".
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.
41
+ *
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.
48
+ */
49
+ function parseMetaString(metaString: ElementData['meta']): Record<string, string> {
50
+ if (!metaString) return {}
51
+
52
+ const props: Record<string, string> = {}
53
+
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
+ }
59
+
60
+ return props
61
+ }
62
+
63
+ // Simple function to convert a camelCase or PascalCase string to kebab-case.
64
+ function kebabCase(str: string) {
65
+ return str
66
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
67
+ .replace('_', '-')
68
+ .toLowerCase()
69
+ }
@@ -0,0 +1,192 @@
1
+ export { remarkDetype }
2
+
3
+ import type { Root, Parent, Code } from 'mdast'
4
+ import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx'
5
+ import type { VFile } from '@mdx-js/mdx/internal-create-format-aware-processors'
6
+ import { visit } from 'unist-util-visit'
7
+ import { assertUsage } from '../utils/assert.js'
8
+ import { parseMetaString } from './rehypeMetaToProps.js'
9
+ import pc from '@brillout/picocolors'
10
+ import module from 'node:module'
11
+ // Cannot use `import { transform } from 'detype'` as it results in errors,
12
+ // and the package has no default export. Using `module.createRequire` instead.
13
+ const { transform: detype } = module.createRequire(import.meta.url)('detype') as typeof import('detype')
14
+
15
+ const prettierOptions: NonNullable<Parameters<typeof detype>[2]>['prettierOptions'] = {
16
+ semi: false,
17
+ singleQuote: true,
18
+ trailingComma: 'none',
19
+ }
20
+
21
+ type CodeNode = {
22
+ codeBlock: Code
23
+ index: number
24
+ parent: Parent
25
+ }
26
+
27
+ function remarkDetype() {
28
+ return async function transformer(tree: Root, file: VFile) {
29
+ const code_nodes: CodeNode[] = []
30
+
31
+ visit(tree, 'code', (node, index, parent) => {
32
+ if (!parent || typeof index === 'undefined') return
33
+ // Skip if `node.lang` is not ts, tsx, vue, or yaml
34
+ if (!['ts', 'tsx', 'vue', 'yaml'].includes(node.lang || '')) return
35
+
36
+ // Skip if 'ts-only' meta is present
37
+ if (node.meta?.includes('ts-only')) return
38
+
39
+ // Collect this node for post-processing
40
+ code_nodes.push({ codeBlock: node, index, parent })
41
+ })
42
+
43
+ for (const node of code_nodes.reverse()) {
44
+ if (node.codeBlock.lang === 'yaml') {
45
+ transformYaml(node)
46
+ } else {
47
+ await transformTsToJs(node, file)
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ function transformYaml(node: CodeNode) {
54
+ const { codeBlock, index, parent } = node
55
+ const codeBlockContentJs = replaceFileNameSuffixes(codeBlock.value)
56
+
57
+ // Skip wrapping if the YAML code block hasn't changed
58
+ if (codeBlockContentJs === codeBlock.value) return
59
+
60
+ const { position, ...rest } = codeBlock
61
+
62
+ // Create a new code node for the JS version based on the modified YAML
63
+ const yamlJsCode: Code = {
64
+ ...rest,
65
+ value: codeBlockContentJs,
66
+ }
67
+
68
+ // Wrap both the original YAML and `yamlJsCode` with <CodeSnippets>
69
+ const yamlContainer: MdxJsxFlowElement = {
70
+ type: 'mdxJsxFlowElement',
71
+ name: 'CodeSnippets',
72
+ children: [yamlJsCode, codeBlock],
73
+ attributes: [
74
+ {
75
+ name: 'hideToggle',
76
+ type: 'mdxJsxAttribute',
77
+ },
78
+ ],
79
+ }
80
+ parent.children.splice(index, 1, yamlContainer)
81
+ }
82
+
83
+ async function transformTsToJs(node: CodeNode, file: VFile) {
84
+ const { codeBlock, index, parent } = node
85
+ const maxWidth = Number(parseMetaString(codeBlock.meta)['max-width'])
86
+ let codeBlockReplacedJs = replaceFileNameSuffixes(codeBlock.value)
87
+ let codeBlockContentJs = ''
88
+
89
+ // Remove TypeScript from the TS/TSX/Vue code node
90
+ try {
91
+ codeBlockContentJs = await detype(codeBlockReplacedJs, `some-dummy-filename.${codeBlock.lang}`, {
92
+ customizeBabelConfig(config) {
93
+ // Add `onlyRemoveTypeImports: true` to the internal `@babel/preset-typescript` config
94
+ // https://github.com/cyco130/detype/blob/46ec867e9efd31d31a312a215ca169bd6bff4726/src/transform.ts#L206
95
+ assertUsage(config.presets && config.presets.length === 1, 'Unexpected Babel config presets')
96
+ config.presets = [[config.presets[0], { onlyRemoveTypeImports: true }]]
97
+ },
98
+ removeTsComments: true,
99
+ prettierOptions: {
100
+ ...prettierOptions,
101
+ printWidth: maxWidth ? maxWidth : 99,
102
+ },
103
+ })
104
+ } catch (error) {
105
+ // Log errors and return original content instead of throwing
106
+ console.error(pc.red((error as SyntaxError).message))
107
+ console.error(
108
+ [
109
+ `Failed to transform the code block in: ${pc.bold(pc.blue(file.path))}.`,
110
+ "This likely happened due to invalid TypeScript syntax (see detype's error message above). You can either:",
111
+ '- Fix the code block syntax',
112
+ '- Set the code block language to js instead of ts',
113
+ '- Use custom meta or comments https://github.com/brillout/docpress#detype',
114
+ ].join('\n') + '\n',
115
+ )
116
+ return
117
+ }
118
+
119
+ // Clean up both JS and TS code contents: correct diff comments (for js only) and apply custom magic comment replacements
120
+ codeBlockContentJs = cleanUpCode(codeBlockContentJs.trimEnd(), true)
121
+ codeBlock.value = cleanUpCode(codeBlock.value)
122
+
123
+ // No wrapping needed if JS and TS code are still identical
124
+ if (codeBlockContentJs === codeBlock.value) return
125
+
126
+ const { position, lang, ...rest } = codeBlock
127
+ const attributes: MdxJsxFlowElement['attributes'] = []
128
+
129
+ const jsCode: Code = {
130
+ ...rest,
131
+ // The jsCode lang should be js|jsx|vue
132
+ lang: lang!.replace('t', 'j'),
133
+ value: codeBlockContentJs,
134
+ }
135
+
136
+ // Add `hideToggle` attribute (prop) to `CodeSnippets` if the only change was replacing `.ts` with `.js`
137
+ if (codeBlockReplacedJs === codeBlockContentJs) {
138
+ attributes.push({
139
+ name: 'hideToggle',
140
+ type: 'mdxJsxAttribute',
141
+ })
142
+ }
143
+
144
+ // Wrap both the original `codeBlock` and `jsCode` with <CodeSnippets>
145
+ const container: MdxJsxFlowElement = {
146
+ type: 'mdxJsxFlowElement',
147
+ name: 'CodeSnippets',
148
+ children: [jsCode, codeBlock],
149
+ attributes,
150
+ }
151
+ parent.children.splice(index, 1, container)
152
+ }
153
+
154
+ // Replace all '.ts' extensions with '.js'
155
+ function replaceFileNameSuffixes(codeBlockValue: string) {
156
+ return codeBlockValue.replaceAll('.ts', '.js')
157
+ }
158
+
159
+ function cleanUpCode(code: string, isJsCode: boolean = false) {
160
+ if (isJsCode) {
161
+ code = correctCodeDiffComments(code)
162
+ }
163
+ return processMagicComments(code)
164
+ }
165
+ function processMagicComments(code: string) {
166
+ // @detype-replace DummyLayout Layout
167
+ const renameCommentRE = /^\/\/\s@detype-replace\s([^ ]+) ([^ ]+)\n/gm
168
+ const matches = Array.from(code.matchAll(renameCommentRE))
169
+
170
+ if (matches.length) {
171
+ for (let i = matches.length - 1; i >= 0; i--) {
172
+ const match = matches[i]
173
+ const [fullMatch, renameFrom, renameTo] = match
174
+ code = code.split(fullMatch).join('').replaceAll(renameFrom, renameTo)
175
+ }
176
+ }
177
+ code = code.replaceAll('// @detype-uncomment ', '')
178
+
179
+ return code
180
+ }
181
+ /**
182
+ * Correct code diff comments that detype() unexpectedly reformatted (using Prettier and Babel internally)
183
+ * after removing TypeScript.
184
+ * See https://github.com/brillout/docpress/pull/47#issuecomment-3263953899
185
+ * @param code Transformed Javascript code.
186
+ * @returns The corrected code.
187
+ */
188
+ function correctCodeDiffComments(code: string) {
189
+ // Expected to match the code diff comments: `// [!code ++]` and `// [!code --]` started with newline and optional spaces
190
+ const codeDiffRE = /\n\s*\/\/\s\[!code.+\]/g
191
+ return code.replaceAll(codeDiffRE, (codeDiff) => codeDiff.trimStart())
192
+ }
@@ -0,0 +1,61 @@
1
+ export { shikiTransformerAutoLinks }
2
+
3
+ import type { ShikiTransformer } from 'shiki'
4
+
5
+ const linkRE = /https:\/\/[^\s]*[^.,\s"'`]/g
6
+
7
+ /**
8
+ * A Shiki transformer that converts plain HTTPS URLs in code blocks into clickable `<a>` links.
9
+ *
10
+ * Inspired by `@jcayzac/shiki-transformer-autolinks`, but tailored for a narrower use case.
11
+ */
12
+ function shikiTransformerAutoLinks(): ShikiTransformer {
13
+ return {
14
+ name: 'docpress-shiki-autolinks',
15
+ span(span) {
16
+ if (span.children.length !== 1) return
17
+ let child = span.children[0]
18
+ if (child.type !== 'text') return
19
+
20
+ const links: { href: string; index: number }[] = []
21
+ const matches = Array.from(child.value.matchAll(linkRE))
22
+
23
+ // Filter out URLs that contain `${...}`. e.g. `https://star-wars.brillout.com/api/films/${id}.json`.
24
+ const filtered = matches.filter(([href]) => !href.includes('${'))
25
+ if (filtered.length === 0) return
26
+
27
+ for (const match of filtered) {
28
+ const [href] = match
29
+ links.unshift({ href, index: match.index })
30
+ }
31
+
32
+ const newChildren: typeof span.children = []
33
+ for (const { href, index } of links) {
34
+ const postIndex = index + href.length
35
+ const postValue = child.value.slice(postIndex)
36
+
37
+ if (postValue.length > 0) {
38
+ newChildren.unshift({ type: 'text', value: postValue })
39
+ }
40
+
41
+ newChildren.unshift({
42
+ type: 'element',
43
+ tagName: 'a',
44
+ properties: { href },
45
+ children: [{ type: 'text', value: href }],
46
+ })
47
+
48
+ child = {
49
+ type: 'text',
50
+ value: child.value.slice(0, index),
51
+ }
52
+ }
53
+
54
+ if (child.value.length > 0) {
55
+ newChildren.unshift(child)
56
+ }
57
+
58
+ span.children = newChildren
59
+ },
60
+ }
61
+ }