@primer/doctocat-nextjs 0.0.0-20260121104552 → 0.0.0-20260129001930

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,16 +1,30 @@
1
1
  # @primer/doctocat-nextjs
2
2
 
3
- ## 0.0.0-20260121104552
3
+ ## 0.0.0-20260129001930
4
4
 
5
5
  ### Patch Changes
6
6
 
7
7
  - Fake entry to force publishing
8
8
 
9
- ## 0.0.0-20260121104550
9
+ ## 0.0.0-20260129001928
10
+
11
+ ### Minor Changes
12
+
13
+ - [#87](https://github.com/primer/doctocat-nextjs/pull/87) [`a0aa353`](https://github.com/primer/doctocat-nextjs/commit/a0aa353c296543f98d5a32d38f3d5631a7081f96) Thanks [@rezrah](https://github.com/rezrah)! - Upgrades Next.js and Nextra.
14
+ - **Next.js v16.x is now required**. v15.x is no longer supported.
15
+ - `nextra` version should be > `4.6.1`
16
+
17
+ | Package | From | To |
18
+ | ----------- | ---- | ------ | --- |
19
+ | `next` | 15.x | 16.1.6 |
20
+ | `@next/mdx` | 15.x | 16.1.6 |
21
+ | `nextra` | 4.x | 4.6.1 | . |
22
+
23
+ ## 0.8.1
10
24
 
11
25
  ### Patch Changes
12
26
 
13
- - [#83](https://github.com/primer/doctocat-nextjs/pull/83) [`3b5fd59`](https://github.com/primer/doctocat-nextjs/commit/3b5fd59c47a1c86030457daa92b557f65e2e13ff) Thanks [@danielguillan](https://github.com/danielguillan)! - Remove underlines from header links
27
+ - [#83](https://github.com/primer/doctocat-nextjs/pull/83) [`3c3b1a9`](https://github.com/primer/doctocat-nextjs/commit/3c3b1a95ff8a301ea81b67f393aad7a0d4ab52f9) Thanks [@danielguillan](https://github.com/danielguillan)! - Remove underlines from header links
14
28
 
15
29
  - [#80](https://github.com/primer/doctocat-nextjs/pull/80) [`772d8de`](https://github.com/primer/doctocat-nextjs/commit/772d8de1b580f7158262f313952227e282035003) Thanks [@danielguillan](https://github.com/danielguillan)! - Improves accessibility of anchor links by adding an underline
16
30
 
@@ -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
 
@@ -35,8 +35,9 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
35
35
  const uniqueId = useId()
36
36
  const {colorMode, setColorMode} = useColorMode()
37
37
  const {basePath} = useConfig()
38
- const initialCode = props.code || getCodeFromChildren(props.children)
39
- const [code, setCode] = useState(initialCode)
38
+ const codeSourceRef = useRef<HTMLDivElement>(null)
39
+ const [code, setCode] = useState<string>('')
40
+ const [isReady, setIsReady] = useState(false)
40
41
  const rootRef = useRef<HTMLDivElement>(null)
41
42
  const [isCodePaneCollapsed, setIsCodePaneCollapsed] = useState<boolean | null>(null)
42
43
  const [initialPosition, setInitialPosition] = useState<number | null>(null)
@@ -44,6 +45,16 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
44
45
  const resetButtonRef = useRef<HTMLButtonElement>(null)
45
46
  const shouldShowPreview = ['tsx', 'jsx'].includes(props['data-language'])
46
47
 
48
+ useEffect(() => {
49
+ if (codeSourceRef.current && !isReady) {
50
+ const textContent = codeSourceRef.current.textContent || ''
51
+ if (textContent) {
52
+ setCode(textContent)
53
+ setIsReady(true)
54
+ }
55
+ }
56
+ }, [isReady, props.children])
57
+
47
58
  // scroll back to the initial y pos on collapse state change
48
59
  useEffect(() => {
49
60
  if (rootRef.current && initialPosition === null) {
@@ -71,8 +82,10 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
71
82
  )
72
83
 
73
84
  const handleReset = useCallback(() => {
74
- setCode(initialCode)
75
- }, [initialCode, setCode])
85
+ if (codeSourceRef.current) {
86
+ setCode(codeSourceRef.current.textContent || '')
87
+ }
88
+ }, [])
76
89
 
77
90
  const handleCopy = useCallback(() => {
78
91
  navigator.clipboard.writeText(code)
@@ -132,88 +145,77 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
132
145
 
133
146
  return (
134
147
  <>
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} />
148
+ {/* Hidden el to render children and extract text content */}
149
+ <div ref={codeSourceRef} style={{display: 'none'}} aria-hidden="true">
150
+ {props.children}
151
+ </div>
152
+ {isReady && (
153
+ <LiveProvider transformCode={transformCodeWithBasePath} code={code} scope={props.jsxScope} noInline={noInline}>
154
+ <div ref={rootRef} className={clsx(styles.CodeBlock, 'custom-component')}>
155
+ {shouldShowPreview && (
156
+ <div>
157
+ <div className={styles.colorModeMenu}>
158
+ <ActionBar aria-label="Toolbar">
159
+ {colorModes.map((mode, index) => {
160
+ const Icon = mode === 'light' ? SunIcon : MoonIcon
161
+ return (
162
+ <ActionBar.IconButton
163
+ className={clsx(styles.colorModeButton, colorMode === mode && styles.colorModeButtonActive)}
164
+ key={`color-mode-${mode}-${index}`}
165
+ icon={Icon}
166
+ aria-label={mode}
167
+ onClick={() => setColorMode(mode)}
168
+ />
169
+ )
170
+ })}
171
+ </ActionBar>
158
172
  </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} />
173
+ <ThemeProvider colorMode={colorMode}>
174
+ <div className="custom-component">
175
+ <LivePreview className={styles.Preview} />
176
+ </div>
177
+ </ThemeProvider>
178
+ </div>
179
+ )}
180
+ <div className={styles.Toolbar}>
181
+ <Button size="small" leadingVisual={CopyIcon} onClick={handleCopy}>
182
+ Copy
183
+ </Button>
184
+ <Button size="small" leadingVisual={UndoIcon} onClick={handleReset} ref={resetButtonRef}>
185
+ Reset
186
+ </Button>
177
187
  </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'}
188
+ <div className={styles.EditorWrapper}>
189
+ <div
190
+ className={clsx(styles.Editor, isCodePaneCollapsed && styles['Editor--is-collapsed'])}
191
+ ref={editorRef}
192
+ id={`${uniqueId}-code-editor-content`}
185
193
  >
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>
194
+ <LiveEditor theme={colorMode === 'light' ? lightTheme : darkTheme} onChange={setCode} />
195
+ </div>
196
+ {shouldShowCollapse && (
197
+ <button
198
+ className={clsx(styles.collapseButton, isCodePaneCollapsed && styles['collapseButton--collapsed'])}
199
+ onClick={handleCollapsibleCodePane}
200
+ aria-expanded={!isCodePaneCollapsed}
201
+ aria-controls={`${uniqueId}-code-editor-content`}
202
+ aria-label={isCodePaneCollapsed ? 'Show full code block' : 'Collapse code block'}
203
+ >
204
+ <Text size="100" className={styles.collapseLabel}>
205
+ {isCodePaneCollapsed ? 'Show full code' : 'Collapse code'}
206
+ </Text>
207
+ <Text variant="muted">{isCodePaneCollapsed ? <UnfoldIcon /> : <FoldIcon />}</Text>
208
+ </button>
209
+ )}
210
+ </div>
211
+ {shouldShowPreview && (
212
+ <div className={styles.Error}>
213
+ <LiveError />
214
+ </div>
191
215
  )}
192
216
  </div>
193
- {shouldShowPreview && (
194
- <div className={styles.Error}>
195
- <LiveError />
196
- </div>
197
- )}
198
- </div>
199
- </LiveProvider>
217
+ </LiveProvider>
218
+ )}
200
219
  </>
201
220
  )
202
221
  }
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
@@ -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
@@ -173,14 +173,11 @@
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
175
  color: var(--brand-InlineLink-color-rest);
176
- position: relative;
177
- }
178
-
179
- .Prose a:hover:not(:global(.custom-component) a:hover) {
180
176
  text-decoration: underline;
177
+ position: relative;
181
178
  }
182
179
 
183
- .Prose a::after:not(:global(.custom-component) a::after) {
180
+ .Prose a:not(:global(.custom-component) a)::after {
184
181
  content: '';
185
182
  border-bottom: var(--brand-borderWidth-thin) solid var(--brand-InlineLink-color-rest);
186
183
  height: 0;
@@ -193,11 +190,11 @@
193
190
  left: 0;
194
191
  }
195
192
 
196
- .Prose a:hover::after:not(:global(.custom-component) a:hover::after) {
193
+ .Prose a:not(:global(.custom-component) a):hover::after {
197
194
  transform: scaleY(var(--brand-InlineLink-transition-scale-end));
198
195
  }
199
196
 
200
- .Prose a:active::after:not(:global(.custom-component) a:active::after) {
197
+ .Prose a:not(:global(.custom-component) a):active::after {
201
198
  border-color: var(--brand-InlineLink-color-pressed);
202
199
  }
203
200
 
@@ -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.0.0-20260121104552",
3
+ "version": "0.0.0-20260129001930",
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
  }