@primer/doctocat-nextjs 0.8.1-rc.bc6f4fb → 0.9.0-rc.f3a7116

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @primer/doctocat-nextjs
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#87](https://github.com/primer/doctocat-nextjs/pull/87) [`7a28f1d`](https://github.com/primer/doctocat-nextjs/commit/7a28f1d707bcfd54b95d16d74e3bdea96eae252c) Thanks [@rezrah](https://github.com/rezrah)! - Upgrades Next.js and Nextra.
8
+ - **Next.js version >= v16.x is now required**. v15.x is no longer supported.
9
+ - `nextra` version is now `4.6.x`
10
+
3
11
  ## 0.8.1
4
12
 
5
13
  ### Patch Changes
@@ -1,7 +1,6 @@
1
1
  'use client'
2
2
  import React, {PropsWithChildren} from 'react'
3
3
  import dynamic from 'next/dynamic'
4
- import {renderToStaticMarkup} from 'react-dom/server'
5
4
 
6
5
  import {Pre} from 'nextra/components'
7
6
  import {Box, Stack, Text} from '@primer/react-brand'
@@ -34,42 +33,6 @@ type CodeBlockProps = {
34
33
 
35
34
  export function CodeBlock(props: CodeBlockProps) {
36
35
  if (['tsx', 'jsx'].includes(props['data-language'])) {
37
- // Next.js v15.4+ will lazy render components on the server, which prevents us from
38
- // sending usable React nodes to the ReactCodeBlock component.
39
- // Workaround is to convert the code snippets to string on the client and pass to react-live.
40
-
41
- // suppresses compilation warnings
42
- if (typeof window !== 'undefined') {
43
- try {
44
- const childrenAsString = renderToStaticMarkup(<>{props.children}</>)
45
-
46
- // Extract text content using browser's HTML parser (immune to regex bypass attacks)
47
- const cleanHtmlTag = (str: string): string => {
48
- const parser = new DOMParser()
49
- const doc = parser.parseFromString(str, 'text/html')
50
- return doc.body.textContent || doc.body.innerText || ''
51
- }
52
-
53
- const textContent = cleanHtmlTag(childrenAsString)
54
-
55
- // Restore escaped chars
56
- const decodedText = textContent
57
- .replace(/&lt;/g, '<')
58
- .replace(/&gt;/g, '>')
59
- .replace(/&quot;/g, '"')
60
- .replace(/&#x27;/g, "'")
61
- .replace(/&amp;/g, '&')
62
-
63
- return <ReactCodeBlock {...props} code={decodedText} />
64
- } catch (error) {
65
- // eslint-disable-next-line no-console
66
- console.log('Error extracting code snippet. Forwarding children directly:', error)
67
- // Fallback to original children-based approach
68
- return <ReactCodeBlock {...props} />
69
- }
70
- }
71
-
72
- // During SSR/build, use children-based approach
73
36
  return <ReactCodeBlock {...props} />
74
37
  }
75
38
 
@@ -18,7 +18,6 @@ const COLLAPSE_HEIGHT = 400 // TODO: Hoist this to config to make user customiza
18
18
  type ReactCodeBlockProps = {
19
19
  'data-language': string
20
20
  'data-filename'?: string
21
- code?: string
22
21
  jsxScope: Record<string, unknown>
23
22
  } & PropsWithChildren<HTMLElement>
24
23
 
@@ -35,8 +34,9 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
35
34
  const uniqueId = useId()
36
35
  const {colorMode, setColorMode} = useColorMode()
37
36
  const {basePath} = useConfig()
38
- const initialCode = props.code || getCodeFromChildren(props.children)
39
- const [code, setCode] = useState(initialCode)
37
+ const codeSourceRef = useRef<HTMLDivElement>(null)
38
+ const [code, setCode] = useState<string>('')
39
+ const [isReady, setIsReady] = useState(false)
40
40
  const rootRef = useRef<HTMLDivElement>(null)
41
41
  const [isCodePaneCollapsed, setIsCodePaneCollapsed] = useState<boolean | null>(null)
42
42
  const [initialPosition, setInitialPosition] = useState<number | null>(null)
@@ -44,6 +44,16 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
44
44
  const resetButtonRef = useRef<HTMLButtonElement>(null)
45
45
  const shouldShowPreview = ['tsx', 'jsx'].includes(props['data-language'])
46
46
 
47
+ useEffect(() => {
48
+ if (codeSourceRef.current && !isReady) {
49
+ const textContent = codeSourceRef.current.textContent || ''
50
+ if (textContent) {
51
+ setCode(textContent)
52
+ setIsReady(true)
53
+ }
54
+ }
55
+ }, [isReady, props.children])
56
+
47
57
  // scroll back to the initial y pos on collapse state change
48
58
  useEffect(() => {
49
59
  if (rootRef.current && initialPosition === null) {
@@ -71,8 +81,10 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
71
81
  )
72
82
 
73
83
  const handleReset = useCallback(() => {
74
- setCode(initialCode)
75
- }, [initialCode, setCode])
84
+ if (codeSourceRef.current) {
85
+ setCode(codeSourceRef.current.textContent || '')
86
+ }
87
+ }, [])
76
88
 
77
89
  const handleCopy = useCallback(() => {
78
90
  navigator.clipboard.writeText(code)
@@ -132,88 +144,77 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
132
144
 
133
145
  return (
134
146
  <>
135
- <LiveProvider transformCode={transformCodeWithBasePath} code={code} scope={props.jsxScope} noInline={noInline}>
136
- <div ref={rootRef} className={clsx(styles.CodeBlock, 'custom-component')}>
137
- {shouldShowPreview && (
138
- <div>
139
- <div className={styles.colorModeMenu}>
140
- <ActionBar aria-label="Toolbar">
141
- {colorModes.map((mode, index) => {
142
- const Icon = mode === 'light' ? SunIcon : MoonIcon
143
- return (
144
- <ActionBar.IconButton
145
- className={clsx(styles.colorModeButton, colorMode === mode && styles.colorModeButtonActive)}
146
- key={`color-mode-${mode}-${index}`}
147
- icon={Icon}
148
- aria-label={mode}
149
- onClick={() => setColorMode(mode)}
150
- />
151
- )
152
- })}
153
- </ActionBar>
154
- </div>
155
- <ThemeProvider colorMode={colorMode}>
156
- <div className="custom-component">
157
- <LivePreview className={styles.Preview} />
147
+ {/* Hidden el to render children and extract text content */}
148
+ <div ref={codeSourceRef} style={{display: 'none'}} aria-hidden="true">
149
+ {props.children}
150
+ </div>
151
+ {isReady && (
152
+ <LiveProvider transformCode={transformCodeWithBasePath} code={code} scope={props.jsxScope} noInline={noInline}>
153
+ <div ref={rootRef} className={clsx(styles.CodeBlock, 'custom-component')}>
154
+ {shouldShowPreview && (
155
+ <div>
156
+ <div className={styles.colorModeMenu}>
157
+ <ActionBar aria-label="Toolbar">
158
+ {colorModes.map((mode, index) => {
159
+ const Icon = mode === 'light' ? SunIcon : MoonIcon
160
+ return (
161
+ <ActionBar.IconButton
162
+ className={clsx(styles.colorModeButton, colorMode === mode && styles.colorModeButtonActive)}
163
+ key={`color-mode-${mode}-${index}`}
164
+ icon={Icon}
165
+ aria-label={mode}
166
+ onClick={() => setColorMode(mode)}
167
+ />
168
+ )
169
+ })}
170
+ </ActionBar>
158
171
  </div>
159
- </ThemeProvider>
160
- </div>
161
- )}
162
- <div className={styles.Toolbar}>
163
- <Button size="small" leadingVisual={CopyIcon} onClick={handleCopy}>
164
- Copy
165
- </Button>
166
- <Button size="small" leadingVisual={UndoIcon} onClick={handleReset} ref={resetButtonRef}>
167
- Reset
168
- </Button>
169
- </div>
170
- <div className={styles.EditorWrapper}>
171
- <div
172
- className={clsx(styles.Editor, isCodePaneCollapsed && styles['Editor--is-collapsed'])}
173
- ref={editorRef}
174
- id={`${uniqueId}-code-editor-content`}
175
- >
176
- <LiveEditor theme={colorMode === 'light' ? lightTheme : darkTheme} onChange={setCode} />
172
+ <ThemeProvider colorMode={colorMode}>
173
+ <div className="custom-component">
174
+ <LivePreview className={styles.Preview} />
175
+ </div>
176
+ </ThemeProvider>
177
+ </div>
178
+ )}
179
+ <div className={styles.Toolbar}>
180
+ <Button size="small" leadingVisual={CopyIcon} onClick={handleCopy}>
181
+ Copy
182
+ </Button>
183
+ <Button size="small" leadingVisual={UndoIcon} onClick={handleReset} ref={resetButtonRef}>
184
+ Reset
185
+ </Button>
177
186
  </div>
178
- {shouldShowCollapse && (
179
- <button
180
- className={clsx(styles.collapseButton, isCodePaneCollapsed && styles['collapseButton--collapsed'])}
181
- onClick={handleCollapsibleCodePane}
182
- aria-expanded={!isCodePaneCollapsed}
183
- aria-controls={`${uniqueId}-code-editor-content`}
184
- aria-label={isCodePaneCollapsed ? 'Show full code block' : 'Collapse code block'}
187
+ <div className={styles.EditorWrapper}>
188
+ <div
189
+ className={clsx(styles.Editor, isCodePaneCollapsed && styles['Editor--is-collapsed'])}
190
+ ref={editorRef}
191
+ id={`${uniqueId}-code-editor-content`}
185
192
  >
186
- <Text size="100" className={styles.collapseLabel}>
187
- {isCodePaneCollapsed ? 'Show full code' : 'Collapse code'}
188
- </Text>
189
- <Text variant="muted">{isCodePaneCollapsed ? <UnfoldIcon /> : <FoldIcon />}</Text>
190
- </button>
193
+ <LiveEditor theme={colorMode === 'light' ? lightTheme : darkTheme} onChange={setCode} />
194
+ </div>
195
+ {shouldShowCollapse && (
196
+ <button
197
+ className={clsx(styles.collapseButton, isCodePaneCollapsed && styles['collapseButton--collapsed'])}
198
+ onClick={handleCollapsibleCodePane}
199
+ aria-expanded={!isCodePaneCollapsed}
200
+ aria-controls={`${uniqueId}-code-editor-content`}
201
+ aria-label={isCodePaneCollapsed ? 'Show full code block' : 'Collapse code block'}
202
+ >
203
+ <Text size="100" className={styles.collapseLabel}>
204
+ {isCodePaneCollapsed ? 'Show full code' : 'Collapse code'}
205
+ </Text>
206
+ <Text variant="muted">{isCodePaneCollapsed ? <UnfoldIcon /> : <FoldIcon />}</Text>
207
+ </button>
208
+ )}
209
+ </div>
210
+ {shouldShowPreview && (
211
+ <div className={styles.Error}>
212
+ <LiveError />
213
+ </div>
191
214
  )}
192
215
  </div>
193
- {shouldShowPreview && (
194
- <div className={styles.Error}>
195
- <LiveError />
196
- </div>
197
- )}
198
- </div>
199
- </LiveProvider>
216
+ </LiveProvider>
217
+ )}
200
218
  </>
201
219
  )
202
220
  }
203
-
204
- /**
205
- * Helper function to turn Nextra <code> children into plain text
206
- */
207
- function getCodeFromChildren(children: React.ReactNode) {
208
- const extractText = (node: React.ReactNode): string => {
209
- if (typeof node === 'string') return node
210
- if (Array.isArray(node)) return node.map(extractText).join('')
211
- if (React.isValidElement(node)) {
212
- const element = node as React.ReactElement<{children?: React.ReactNode}>
213
- return extractText(element.props.children)
214
- }
215
- return ''
216
- }
217
-
218
- return extractText(children)
219
- }
@@ -24,7 +24,6 @@ export function useNavDrawerState(breakpoint: string | number): [boolean, (value
24
24
 
25
25
  useEffect(() => {
26
26
  if (isOpen) {
27
- // eslint-disable-next-line github/prefer-observers
28
27
  window.addEventListener('resize', debouncedOnResize)
29
28
  return () => {
30
29
  // cancel any debounced invocation of the resize handler
@@ -12,6 +12,7 @@
12
12
 
13
13
  .BreadcrumbRoot,
14
14
  .BreadcrumbItem {
15
+ --fgColor-link: var(--brand-InlineLink-color-rest);
15
16
  color: var(--brand-InlineLink-color-rest);
16
17
  }
17
18
 
@@ -24,12 +25,10 @@
24
25
  width: 100%;
25
26
  }
26
27
 
27
-
28
28
  .ActionsWrapper {
29
29
  padding-block-start: var(--base-size-16);
30
30
  }
31
31
 
32
-
33
32
  .RelatedLinks {
34
33
  padding-top: var(--base-size-20);
35
34
  }
@@ -43,7 +43,6 @@ const repoSrcPath = process.env.NEXT_PUBLIC_REPO_SRC_PATH || ''
43
43
  const repoURL = process.env.NEXT_PUBLIC_REPO || ''
44
44
 
45
45
  if (!repoURL) {
46
- // eslint-disable-next-line no-console
47
46
  console.warn(
48
47
  'NEXT_PUBLIC_REPO is not set. Edit the .env.local file to set the NEXT_PUBLIC_REPO environment variable.',
49
48
  )
@@ -75,7 +74,6 @@ export function Theme({pageMap, children}: ThemeProps) {
75
74
  [pageMap, fsPath],
76
75
  )
77
76
 
78
- // eslint-disable-next-line i18n-text/no-en
79
77
  const siteTitle = process.env.NEXT_PUBLIC_SITE_TITLE || 'Example Site'
80
78
  const {headerLinks} = useConfig()
81
79
  const activeHeaderLink = headerLinks.find(link => link.isActive)
@@ -56,8 +56,7 @@ export function Sidebar({pageMap}: SidebarProps) {
56
56
  )}
57
57
  <NavList className={styles.NavList} aria-labelledby="nav-list-heading">
58
58
  {reorderedPageMap.map(item => {
59
- if (item.hasOwnProperty('data')) return null
60
-
59
+ if (Object.hasOwn(item, 'data')) return null
61
60
  if (!hasChildren(item)) return null
62
61
 
63
62
  const indexPage = (item as Folder).children.find(child => (child as MdxFile).name === 'index') as MdxFile
@@ -172,35 +172,11 @@
172
172
 
173
173
  .Prose a:not(:global(.custom-component) a),
174
174
  .Prose a:-webkit-any-link:not(:global(.custom-component) a:-webkit-any-link) {
175
+ --fgColor-accent: var(--brand-InlineLink-color-rest);
175
176
  color: var(--brand-InlineLink-color-rest);
176
- position: relative;
177
- }
178
-
179
- .Prose a:hover:not(:global(.custom-component) a:hover) {
180
177
  text-decoration: underline;
181
178
  }
182
179
 
183
- .Prose a::after:not(:global(.custom-component) a::after) {
184
- content: '';
185
- border-bottom: var(--brand-borderWidth-thin) solid var(--brand-InlineLink-color-rest);
186
- height: 0;
187
- transition: var(--brand-InlineLink-transition-timing) transform;
188
- transform: scaleY(var(--brand-InlineLink-transition-scale-start));
189
- transform-origin: 50% 100%;
190
- width: 100%;
191
- position: absolute;
192
- bottom: 0;
193
- left: 0;
194
- }
195
-
196
- .Prose a:hover::after:not(:global(.custom-component) a:hover::after) {
197
- transform: scaleY(var(--brand-InlineLink-transition-scale-end));
198
- }
199
-
200
- .Prose a:active::after:not(:global(.custom-component) a:active::after) {
201
- border-color: var(--brand-InlineLink-color-pressed);
202
- }
203
-
204
180
  .Prose code:not(:global(.custom-component) code) {
205
181
  font-family: var(--brand-fontStack-monospace);
206
182
  font-size: var(--brand-text-size-100);
@@ -0,0 +1,18 @@
1
+ import baseConfig from '../../eslint.config.js'
2
+ import reactHooksPlugin from 'eslint-plugin-react-hooks'
3
+
4
+ export default [
5
+ ...baseConfig,
6
+
7
+ // React Hooks rules
8
+ {
9
+ files: ['**/*.{js,jsx,ts,tsx}'],
10
+ plugins: {
11
+ 'react-hooks': reactHooksPlugin,
12
+ },
13
+ rules: {
14
+ 'react-hooks/rules-of-hooks': 'error',
15
+ 'react-hooks/exhaustive-deps': 'warn',
16
+ },
17
+ },
18
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primer/doctocat-nextjs",
3
- "version": "0.8.1-rc.bc6f4fb",
3
+ "version": "0.9.0-rc.f3a7116",
4
4
  "description": "A Next.js theme for building Primer documentation sites",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "check": "tsc --noEmit",
13
- "lint": "eslint '**/*.{js,ts,tsx,md,mdx}' --max-warnings=0 --config ./.eslintrc.cjs",
13
+ "lint": "eslint '**/*.{js,ts,tsx,md,mdx}' --max-warnings=0",
14
14
  "test": "vitest",
15
15
  "test:ui": "vitest --ui",
16
16
  "test:no-watch": "vitest run",
@@ -20,19 +20,20 @@
20
20
  "license": "MIT",
21
21
  "peerDependencies": {
22
22
  "@primer/react-brand": ">=0.54.0",
23
- "next": "^15.0.0",
23
+ "next": "^16.0.0",
24
24
  "react": ">=18.0.0 <20.0.0",
25
25
  "react-dom": ">=18.0.0 <20.0.0"
26
26
  },
27
27
  "dependencies": {
28
- "@next/mdx": "15.5.7",
28
+ "@next/mdx": "16.1.6",
29
29
  "@primer/octicons-react": "19.15.1",
30
30
  "@primer/react": "^38.3.0",
31
31
  "@types/lodash.debounce": "^4.0.9",
32
32
  "framer-motion": "12.23.24",
33
33
  "lodash.debounce": "^4.0.8",
34
- "nextra": "4.4.0",
34
+ "nextra": "4.6.1",
35
35
  "react-focus-on": "^3.10.0",
36
+ "react-is": "^19.2.0",
36
37
  "react-live": "^4.1.8"
37
38
  },
38
39
  "devDependencies": {
@@ -49,7 +50,7 @@
49
50
  "@vitest/ui": "^3.2.4",
50
51
  "clsx": "2.1.1",
51
52
  "jsdom": "^26.0.1",
52
- "next": "15.5.7",
53
+ "next": "16.1.6",
53
54
  "styled-components": "5.3.11",
54
55
  "vitest": "^3.2.4"
55
56
  }