Skip to content

Commit ad4823d

Browse files
author
Dimitri POSTOLOV
authoredSep 5, 2023
[v3] add zod validation for nextraConfig (#2259)

File tree

20 files changed

+331
-575
lines changed

20 files changed

+331
-575
lines changed
 

‎.changeset/loud-schools-decide.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'nextra': major
3+
---
4+
5+
add zod validation for nextraConfig

‎examples/docs/next.config.mjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const withNextra = nextra({
55
themeConfig: './src/theme.config.js',
66
latex: true,
77
search: {
8-
codeblock: false
8+
codeblocks: false
99
}
1010
})
1111

‎examples/docs/src/pages/themes/docs/configuration.mdx

+3-3
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,7 @@ export default {
576576

577577
Enable Nextra built-in search
578578

579-
**Type:** `boolean | { codeblock: boolean }`
579+
**Type:** `boolean | { codeblocks: boolean }`
580580

581581
**Example:**
582582

@@ -587,7 +587,7 @@ const withNextra = nextra({
587587
theme: 'nextra-theme-blog',
588588
themeConfig: './theme.config.js',
589589
search: {
590-
codeblock: false
590+
codeblocks: false
591591
}
592592
})
593593
export default withNextra()
@@ -599,7 +599,7 @@ export default withNextra()
599599

600600
Empty component for search result.
601601

602-
**Type:** `boolean | { codeblock: boolean }`
602+
**Type:** `boolean | { codeblocks: boolean }`
603603

604604
**Example:**
605605

‎examples/swr-site/components/blog.jsx

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import Link from 'next/link'
2+
import { getPagesUnderRoute } from 'nextra/context'
3+
import { useRouter } from 'nextra/hooks'
4+
5+
export function Blog() {
6+
const { locale } = useRouter()
7+
return getPagesUnderRoute(`/${locale}/blog`).map(page => (
8+
<div key={page.route}>
9+
<Link
10+
href={page.route}
11+
className="text-2xl text-black hover:!no-underline dark:text-gray-100"
12+
>
13+
{/* @ts-expect-error TODO: fix type error */}
14+
{page.meta.title || page.frontMatter?.title || page.name}
15+
</Link>
16+
<p className="opacity-80 mt-6 leading-7">
17+
{/* @ts-expect-error TODO: fix type error */}
18+
{page.frontMatter?.description}
19+
<Link
20+
href={page.route}
21+
className="block text-[color:hsl(var(--nextra-primary-hue),100%,50%)] underline underline-offset-2 decoration-from-font"
22+
>
23+
Read more →
24+
</Link>
25+
</p>
26+
{/* @ts-expect-error TODO: fix type error */}
27+
<p>{page.date}</p>
28+
</div>
29+
))
30+
}

‎examples/swr-site/next.config.js

+67-60
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import bundleAnalyzer from '@next/bundle-analyzer'
12
import nextra from 'nextra'
23

34
const withNextra = nextra({
@@ -20,66 +21,72 @@ export function getStaticProps() {
2021
}
2122
})
2223

24+
const withBundleAnalyzer = bundleAnalyzer({
25+
enabled: process.env.ANALYZE === 'true'
26+
})
27+
2328
/**
2429
* @type {import('next').NextConfig}
2530
*/
26-
export default withNextra({
27-
i18n: {
28-
locales: ['en', 'es', 'ru'],
29-
defaultLocale: 'en'
30-
}, // basePath: "/some-base-path",
31-
distDir: './.next', // Nextra supports custom `nextConfig.distDir`
32-
redirects: () => [
33-
// {
34-
// source: "/docs.([a-zA-Z-]+)",
35-
// destination: "/docs/getting-started",
36-
// statusCode: 301,
37-
// },
38-
// {
39-
// source: "/advanced/performance",
40-
// destination: "/docs/advanced/performance",
41-
// statusCode: 301,
42-
// },
43-
// {
44-
// source: "/advanced/cache",
45-
// destination: "/docs/advanced/cache",
46-
// statusCode: 301,
47-
// },
48-
// {
49-
// source: "/docs/cache",
50-
// destination: "/docs/advanced/cache",
51-
// statusCode: 301,
52-
// },
53-
{
54-
source: '/change-log',
55-
destination: '/docs/change-log',
56-
statusCode: 301
57-
},
58-
{
59-
source: '/blog/swr-1',
60-
destination: '/blog/swr-v1',
61-
statusCode: 301
62-
},
63-
{
64-
source: '/docs.([a-zA-Z-]+)',
65-
destination: '/docs/getting-started',
66-
statusCode: 302
67-
},
68-
{
69-
source: '/docs',
70-
destination: '/docs/getting-started',
71-
statusCode: 302
72-
},
73-
{
74-
source: '/examples',
75-
destination: '/examples/basic',
76-
statusCode: 302
77-
},
78-
{
79-
source: '/',
80-
destination: '/en',
81-
permanent: true
82-
}
83-
],
84-
reactStrictMode: true
85-
})
31+
export default withBundleAnalyzer(
32+
withNextra({
33+
i18n: {
34+
locales: ['en', 'es', 'ru'],
35+
defaultLocale: 'en'
36+
}, // basePath: "/some-base-path",
37+
distDir: './.next', // Nextra supports custom `nextConfig.distDir`
38+
redirects: () => [
39+
// {
40+
// source: "/docs.([a-zA-Z-]+)",
41+
// destination: "/docs/getting-started",
42+
// statusCode: 301,
43+
// },
44+
// {
45+
// source: "/advanced/performance",
46+
// destination: "/docs/advanced/performance",
47+
// statusCode: 301,
48+
// },
49+
// {
50+
// source: "/advanced/cache",
51+
// destination: "/docs/advanced/cache",
52+
// statusCode: 301,
53+
// },
54+
// {
55+
// source: "/docs/cache",
56+
// destination: "/docs/advanced/cache",
57+
// statusCode: 301,
58+
// },
59+
{
60+
source: '/change-log',
61+
destination: '/docs/change-log',
62+
statusCode: 301
63+
},
64+
{
65+
source: '/blog/swr-1',
66+
destination: '/blog/swr-v1',
67+
statusCode: 301
68+
},
69+
{
70+
source: '/docs.([a-zA-Z-]+)',
71+
destination: '/docs/getting-started',
72+
statusCode: 302
73+
},
74+
{
75+
source: '/docs',
76+
destination: '/docs/getting-started',
77+
statusCode: 302
78+
},
79+
{
80+
source: '/examples',
81+
destination: '/examples/basic',
82+
statusCode: 302
83+
},
84+
{
85+
source: '/',
86+
destination: '/en',
87+
permanent: true
88+
}
89+
],
90+
reactStrictMode: true
91+
})
92+
)

‎examples/swr-site/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"license": "Apache-2.0",
66
"private": true,
77
"scripts": {
8+
"analyze": "ANALYZE=true pnpm build",
89
"build": "next build",
910
"clean": "rimraf .next .turbo",
1011
"debug": "NODE_OPTIONS='--inspect' next dev",
@@ -33,6 +34,7 @@
3334
}
3435
},
3536
"devDependencies": {
37+
"@next/bundle-analyzer": "^13.4.19",
3638
"autoprefixer": "^10.4.15",
3739
"postcss": "^8.4.28",
3840
"tailwindcss": "^3.3.3"

‎examples/swr-site/pages/en/blog.mdx

+2-28
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,8 @@
22
searchable: false
33
---
44

5-
import Link from 'next/link'
6-
import { getPagesUnderRoute } from 'nextra/context'
7-
import { useRouter } from 'nextra/hooks'
8-
9-
export function Page() {
10-
const { locale } = useRouter()
11-
return getPagesUnderRoute(`/${locale}/blog`).map(page => (
12-
<div key={page.route}>
13-
<Link
14-
href={page.route}
15-
className="text-2xl text-black hover:!no-underline dark:text-gray-100"
16-
>
17-
{page.meta.title || page.frontMatter?.title || page.name}
18-
</Link>
19-
<p className="opacity-80 mt-6 leading-7">
20-
{page.frontMatter?.description}
21-
<Link
22-
href={page.route}
23-
className="block text-[color:hsl(var(--nextra-primary-hue),100%,50%)] underline underline-offset-2 decoration-from-font"
24-
>
25-
Read more →
26-
</Link>
27-
</p>
28-
<p>{page.date}</p>
29-
</div>
30-
))
31-
}
5+
import { Blog } from 'components/blog'
326

337
# SWR Blog
348

35-
<Page />
9+
<Blog />

‎examples/swr-site/pages/ru/blog.mdx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Page } from '../en/blog.mdx'
1+
import { Blog } from 'components/blog'
22

33
# Блог SWR
44

5-
<Page />
5+
<Blog />

‎packages/nextra-theme-docs/src/components/breadcrumb.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import cn from 'clsx'
2+
// eslint-disable-next-line no-restricted-imports -- since we don't need newWindow prop
3+
import NextLink from 'next/link'
24
import { ArrowRightIcon } from 'nextra/icons'
35
import type { Item } from 'nextra/normalize-pages'
46
import type { ReactElement } from 'react'
57
import { Fragment } from 'react'
6-
import { Anchor } from './anchor'
78

89
export function Breadcrumb({
910
activePath
@@ -33,7 +34,7 @@ export function Breadcrumb({
3334
title={item.title}
3435
>
3536
{isLink && !isActive ? (
36-
<Anchor href={item.route}>{item.title}</Anchor>
37+
<NextLink href={item.route}>{item.title}</NextLink>
3738
) : (
3839
item.title
3940
)}

‎packages/nextra-theme-docs/src/components/nav-links.tsx

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import cn from 'clsx'
2+
// eslint-disable-next-line no-restricted-imports -- since we don't need newWindow prop
3+
import NextLink from 'next/link'
24
import { ArrowRightIcon } from 'nextra/icons'
35
import type { Item } from 'nextra/normalize-pages'
46
import type { ReactElement } from 'react'
57
import { useConfig } from '../contexts'
68
import type { DocsThemeConfig } from '../index'
7-
import { Anchor } from './anchor'
89

910
interface NavLinkProps {
1011
currentIndex: number
@@ -43,17 +44,17 @@ export const NavLinks = ({
4344
)}
4445
>
4546
{prev && (
46-
<Anchor
47+
<NextLink
4748
href={prev.route}
4849
title={prev.title}
4950
className={cn(classes.link, 'ltr:nx-pr-4 rtl:nx-pl-4')}
5051
>
5152
<ArrowRightIcon className={cn(classes.icon, 'ltr:nx-rotate-180')} />
5253
{prev.title}
53-
</Anchor>
54+
</NextLink>
5455
)}
5556
{next && (
56-
<Anchor
57+
<NextLink
5758
href={next.route}
5859
title={next.title}
5960
className={cn(
@@ -63,7 +64,7 @@ export const NavLinks = ({
6364
>
6465
{next.title}
6566
<ArrowRightIcon className={cn(classes.icon, 'rtl:nx-rotate-180')} />
66-
</Anchor>
67+
</NextLink>
6768
)}
6869
</div>
6970
)

‎packages/nextra-theme-docs/src/components/navbar.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Menu, Transition } from '@headlessui/react'
22
import cn from 'clsx'
3+
// eslint-disable-next-line no-restricted-imports -- since we don't need newWindow prop
4+
import NextLink from 'next/link'
35
import { useFSRoute } from 'nextra/hooks'
46
import { ArrowRightIcon, MenuIcon } from 'nextra/icons'
57
import type { MenuItem, PageItem } from 'nextra/normalize-pages'
@@ -97,12 +99,12 @@ export function Navbar({ items }: NavBarProps): ReactElement {
9799
/>
98100
<nav className="nx-mx-auto nx-flex nx-h-[var(--nextra-navbar-height)] nx-max-w-[90rem] nx-items-center nx-justify-end nx-gap-2 nx-pl-[max(env(safe-area-inset-left),1.5rem)] nx-pr-[max(env(safe-area-inset-right),1.5rem)]">
99101
{config.logoLink ? (
100-
<Anchor
102+
<NextLink
101103
href={typeof config.logoLink === 'string' ? config.logoLink : '/'}
102104
className="nx-flex nx-items-center hover:nx-opacity-75 ltr:nx-mr-auto rtl:nx-ml-auto"
103105
>
104106
{renderComponent(config.logo)}
105-
</Anchor>
107+
</NextLink>
106108
) : (
107109
<div className="nx-flex nx-items-center ltr:nx-mr-auto rtl:nx-ml-auto">
108110
{renderComponent(config.logo)}

‎packages/nextra-theme-docs/src/components/search.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Transition } from '@headlessui/react'
22
import cn from 'clsx'
3+
// eslint-disable-next-line no-restricted-imports -- since we don't need newWindow prop
4+
import NextLink from 'next/link'
35
import { useRouter } from 'next/router'
46
import { useMounted } from 'nextra/hooks'
57
import { InformationCircleIcon, SpinnerIcon } from 'nextra/icons'
@@ -8,7 +10,6 @@ import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
810
import { useConfig, useMenu } from '../contexts'
911
import type { SearchResult } from '../types'
1012
import { renderComponent, renderString } from '../utils'
11-
import { Anchor } from './anchor'
1213
import { Input } from './input'
1314

1415
type SearchProps = {
@@ -271,7 +272,7 @@ export function Search({
271272
: 'nx-text-gray-800 contrast-more:nx-border-transparent dark:nx-text-gray-300'
272273
)}
273274
>
274-
<Anchor
275+
<NextLink
275276
className="nx-block nx-scroll-m-12 nx-px-2.5 nx-py-2"
276277
href={route}
277278
data-index={i}
@@ -281,7 +282,7 @@ export function Search({
281282
onKeyDown={handleKeyDown}
282283
>
283284
{children}
284-
</Anchor>
285+
</NextLink>
285286
</li>
286287
</Fragment>
287288
))

‎packages/nextra/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@
140140
"title": "^3.5.3",
141141
"unist-util-remove": "^4.0.0",
142142
"unist-util-visit": "^5.0.0",
143-
"zod": "^3.22.2"
143+
"zod": "^3.22.2",
144+
"zod-validation-error": "^1.5.0"
144145
},
145146
"devDependencies": {
146147
"@testing-library/react": "^14.0.0",

‎packages/nextra/src/index.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
/* eslint-env node */
22
import { createRequire } from 'node:module'
33
import type { NextConfig } from 'next'
4+
import type { ZodError } from 'zod'
5+
import { fromZodError } from 'zod-validation-error'
46
import {
57
DEFAULT_CONFIG,
68
DEFAULT_LOCALE,
79
DEFAULT_LOCALES,
810
MARKDOWN_EXTENSION_REGEX,
911
MARKDOWN_EXTENSIONS
1012
} from './constants'
13+
import { nextraConfigSchema } from './schemas'
1114
import type { Nextra } from './types'
1215
import { logger } from './utils'
1316
import { NextraPlugin, NextraSearchPlugin } from './webpack-plugins'
@@ -16,8 +19,15 @@ const DEFAULT_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']
1619

1720
const require = createRequire(import.meta.url)
1821

19-
const nextra: Nextra = nextraConfig =>
20-
function withNextra(nextConfig = {}) {
22+
const nextra: Nextra = nextraConfig => {
23+
try {
24+
nextraConfigSchema.parse(nextraConfig)
25+
} catch (error) {
26+
logger.error('Error validating nextraConfig')
27+
throw fromZodError(error as ZodError)
28+
}
29+
30+
return function withNextra(nextConfig = {}) {
2131
const hasI18n = !!nextConfig.i18n?.locales
2232

2333
if (hasI18n) {
@@ -146,6 +156,7 @@ const nextra: Nextra = nextraConfig =>
146156
}
147157
}
148158
}
159+
}
149160

150161
// TODO: take this type from webpack directly
151162
type RuleSetRule = {

‎packages/nextra/src/schemas.ts

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { ProcessorOptions } from '@mdx-js/mdx'
2+
import type { Options as RehypePrettyCodeOptions } from 'rehype-pretty-code'
3+
import { z } from 'zod'
4+
import type { PageOpts } from './types'
5+
6+
export const searchSchema = z.boolean().or(
7+
z.strictObject({
8+
/**
9+
* Whether to index code blocks
10+
* @default true
11+
*/
12+
codeblocks: z.boolean(),
13+
/**
14+
* A filter function to filter out files from indexing, and return the
15+
* index file key, or null to skip indexing.
16+
* A site can have multiple indexes, by default they're separated by
17+
* locales as multiple index files.
18+
*/
19+
indexKey: z
20+
.custom<
21+
(filepath: string, route: string, locale?: string) => null | string
22+
>()
23+
.optional()
24+
})
25+
)
26+
27+
type Transform = (
28+
result: string,
29+
options: {
30+
route: string
31+
}
32+
) => string | Promise<string>
33+
34+
export const nextraConfigSchema = z
35+
.strictObject({
36+
themeConfig: z.string(),
37+
defaultShowCopyCode: z.boolean(),
38+
search: searchSchema,
39+
staticImage: z.boolean(),
40+
readingTime: z.boolean(),
41+
latex: z.boolean(),
42+
codeHighlight: z.boolean(),
43+
/**
44+
* A function to modify the code of compiled MDX pages.
45+
* @experimental
46+
*/
47+
transform: z.custom<Transform>(),
48+
/**
49+
* A function to modify the `pageOpts` prop passed to theme layouts.
50+
* @experimental
51+
*/
52+
transformPageOpts: z.custom<(pageOpts: PageOpts) => PageOpts>(),
53+
mdxOptions: z.strictObject({
54+
rehypePlugins: z.custom<ProcessorOptions['rehypePlugins']>(),
55+
remarkPlugins: z.custom<ProcessorOptions['remarkPlugins']>(),
56+
format: z.enum(['detect', 'mdx', 'md']),
57+
rehypePrettyCodeOptions: z.custom<RehypePrettyCodeOptions>()
58+
})
59+
})
60+
.deepPartial()
61+
.extend({ theme: z.string() })

‎packages/nextra/src/types.ts

+5-54
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import type { ProcessorOptions } from '@mdx-js/mdx'
21
import type { GrayMatterFile } from 'gray-matter'
32
import type { Heading as MDASTHeading } from 'mdast'
43
import type { NextConfig } from 'next'
54
import type { FC, ReactNode } from 'react'
6-
import type { Options as RehypePrettyCodeOptions } from 'rehype-pretty-code'
5+
import type { z } from 'zod'
76
import type {
87
MARKDOWN_EXTENSIONS,
98
META_FILENAME,
109
NEXTRA_INTERNAL
1110
} from './constants'
11+
import type { nextraConfigSchema, searchSchema } from './schemas'
1212

1313
type MetaFilename = typeof META_FILENAME
1414
type MarkdownExtension = (typeof MARKDOWN_EXTENSIONS)[number]
@@ -101,58 +101,9 @@ export type ReadingTime = {
101101
words: number
102102
}
103103

104-
type Theme = string
105-
export type Search =
106-
| boolean
107-
| {
108-
/**
109-
* Whether to index code blocks
110-
* @default true
111-
*/
112-
codeblocks: boolean
113-
/**
114-
* A filter function to filter out files from indexing, and return the
115-
* index file key, or null to skip indexing.
116-
* A site can have multiple indexes, by default they're separated by
117-
* locales as multiple index files.
118-
*/
119-
indexKey?: (
120-
filepath: string,
121-
route: string,
122-
locale?: string
123-
) => null | string
124-
}
125-
type Transform = (
126-
result: string,
127-
options: {
128-
route: string
129-
}
130-
) => string | Promise<string>
131-
132-
export type NextraConfig = {
133-
theme: Theme
134-
themeConfig?: string
135-
defaultShowCopyCode?: boolean
136-
search?: Search
137-
staticImage?: boolean
138-
readingTime?: boolean
139-
latex?: boolean
140-
codeHighlight?: boolean
141-
/**
142-
* A function to modify the code of compiled MDX pages.
143-
* @experimental
144-
*/
145-
transform?: Transform
146-
/**
147-
* A function to modify the `pageOpts` prop passed to theme layouts.
148-
* @experimental
149-
*/
150-
transformPageOpts?: (pageOpts: PageOpts) => PageOpts
151-
mdxOptions?: Pick<ProcessorOptions, 'rehypePlugins' | 'remarkPlugins'> & {
152-
format?: 'detect' | 'mdx' | 'md'
153-
rehypePrettyCodeOptions?: Partial<RehypePrettyCodeOptions>
154-
}
155-
}
104+
export type Search = z.infer<typeof searchSchema>
105+
106+
export type NextraConfig = z.infer<typeof nextraConfigSchema>
156107

157108
export type Nextra = (
158109
nextraConfig: NextraConfig

‎packages/nextra/src/utils.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -92,5 +92,5 @@ export function getDefault<T>(module: T & { default?: T }): T {
9292
export const logger = {
9393
info: console.log.bind(null, '-', '\x1b[36minfo\x1b[0m', '[nextra]'),
9494
warn: console.log.bind(null, '-', '\x1b[33mwarn\x1b[0m', '[nextra]'),
95-
error: console.log.bind(null, '-', '\x1b[31mwarn\x1b[0m', '[nextra]')
95+
error: console.log.bind(null, '-', '\x1b[31merror\x1b[0m', '[nextra]')
9696
}

‎packages/nextra/tsconfig.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
"lib": ["es2022", "dom"],
1313
"moduleResolution": "node",
1414
"types": ["vitest/globals", "webpack-env"],
15-
"resolveJsonModule": true
15+
"resolveJsonModule": true,
16+
"paths": {
17+
"unified": ["./node_modules/unified"]
18+
}
1619
}
1720
}

‎packages/nextra/tsup.config.ts

-6
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@ export default defineConfig([
7676
'src/**/*.ts',
7777
'!src/**/*.d.ts',
7878
'!src/catch-all.ts',
79-
'!src/types.ts',
8079
...CLIENT_ENTRY.map(filePath => `!${filePath}`)
8180
],
8281
...sharedConfig
@@ -86,10 +85,5 @@ export default defineConfig([
8685
entry: CLIENT_ENTRY,
8786
outExtension: () => ({ js: '.js' }),
8887
...sharedConfig
89-
},
90-
{
91-
entry: ['src/types.ts'],
92-
name: 'nextra-types',
93-
dts: { only: true }
9488
}
9589
])

‎pnpm-lock.yaml

+116-404
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.