Skip to content

Commit

Permalink
use z.strictObject instead z.object in zod theme validation, impr…
Browse files Browse the repository at this point in the history
…ove zod error messages (shuding#1341)

* fix

* add zod validation for _meta.json "theme" property (shuding#1343)

* add zod validation for _meta.json "theme" property

* fix

* try

* rollback
  • Loading branch information
Dimitri POSTOLOV authored Jan 22, 2023
1 parent e10bf74 commit 6a0f428
Show file tree
Hide file tree
Showing 8 changed files with 177 additions and 137 deletions.
5 changes: 5 additions & 0 deletions .changeset/ten-houses-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'nextra-theme-docs': patch
---

add zod validation for _meta.json "theme" property
5 changes: 5 additions & 0 deletions .changeset/thin-coats-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'nextra-theme-docs': patch
---

use `z.strictObject` instead `z.object` in zod theme validation, improve zod error messages
11 changes: 10 additions & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ module.exports = {
'@typescript-eslint/prefer-optional-chain': 'error',
'no-else-return': ['error', { allowElseIf: false }],
'no-lonely-if': 'error',
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
'prefer-destructuring': [
'error',
{ VariableDeclarator: { object: true } }
],
// todo: enable
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
Expand Down Expand Up @@ -59,6 +62,12 @@ module.exports = {
'CallExpression[callee.name=useMemo][arguments.1.type=ArrayExpression][arguments.1.elements.length=0]',
message:
"`useMemo` with an empty dependency array can't provide a stable reference, use `useRef` instead."
},
{
// ❌ z.object(…)
selector:
'MemberExpression[object.name=z] > .property[name=object]',
message: 'Use z.strictObject is more safe.'
}
],
'react/jsx-filename-extension': [
Expand Down
218 changes: 116 additions & 102 deletions packages/nextra-theme-docs/src/constants.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/* eslint sort-keys: error */
import { isValidElement, FC, ReactNode } from 'react'
import { PageTheme } from './types'
import { useRouter } from 'next/router'
import { Anchor, Flexsearch, Footer, Navbar, TOC } from './components'
import { DiscordIcon, GitHubIcon } from 'nextra/icons'
Expand Down Expand Up @@ -34,7 +33,7 @@ function isString(value: unknown): boolean {
}

const i18nSchema = z.array(
z.object({
z.strictObject({
direction: z.enum(['ltr', 'rtl']).optional(),
locale: z.string(),
text: z.string()
Expand All @@ -47,106 +46,104 @@ const reactNode = [
] as const
const fc = [isFunction, { message: 'Must be React.FC' }] as const

export const themeSchema = z
.object({
banner: z.object({
dismissible: z.boolean(),
key: z.string(),
text: z.custom<ReactNode | FC>(...reactNode).optional()
}),
chat: z.object({
icon: z.custom<ReactNode | FC>(...reactNode),
link: z.string().startsWith('https://').optional()
}),
components: z.record(z.custom<FC>(...fc)).optional(),
darkMode: z.boolean(),
direction: z.enum(['ltr', 'rtl']),
docsRepositoryBase: z.string().startsWith('https://'),
editLink: z.object({
component: z.custom<
FC<{
children: ReactNode
className?: string
filePath?: string
}>
>(...fc),
text: z.custom<ReactNode | FC>(...reactNode)
}),
faviconGlyph: z.string().optional(),
feedback: z.object({
content: z.custom<ReactNode | FC>(...reactNode),
labels: z.string(),
useLink: z.function().returns(z.string())
}),
footer: z.object({
component: z.custom<ReactNode | FC<{ menu: boolean }>>(...reactNode),
text: z.custom<ReactNode | FC>(...reactNode)
}),
gitTimestamp: z.custom<ReactNode | FC<{ timestamp: Date }>>(...reactNode),
head: z.custom<ReactNode | FC>(...reactNode),
i18n: i18nSchema,
logo: z.custom<ReactNode | FC>(...reactNode),
logoLink: z.boolean().or(z.string()),
main: z.custom<FC<{ children: ReactNode }>>(...fc).optional(),
navbar: z.object({
component: z.custom<ReactNode | FC<NavBarProps>>(...reactNode),
extraContent: z.custom<ReactNode | FC>(...reactNode).optional()
}),
navigation: z.boolean().or(
z.object({
next: z.boolean(),
prev: z.boolean()
})
),
nextThemes: z.object({
defaultTheme: z.string(),
forcedTheme: z.string().optional(),
storageKey: z.string()
}),
notFound: z.object({
content: z.custom<ReactNode | FC>(...reactNode),
labels: z.string()
}),
primaryHue: z.number().or(
z.object({
dark: z.number(),
light: z.number()
})
),
project: z.object({
icon: z.custom<ReactNode | FC>(...reactNode),
link: z.string().startsWith('https://').optional()
}),
search: z.object({
component: z.custom<
ReactNode | FC<{ className?: string; directories: Item[] }>
>(...reactNode),
emptyResult: z.custom<ReactNode | FC>(...reactNode),
error: z.string().or(z.function().returns(z.string())),
loading: z.string().or(z.function().returns(z.string())),
// Can't be React component
placeholder: z.string().or(z.function().returns(z.string()))
}),
serverSideError: z.object({
content: z.custom<ReactNode | FC>(...reactNode),
labels: z.string()
}),
sidebar: z.object({
defaultMenuCollapseLevel: z.number().min(1).int(),
titleComponent: z.custom<
ReactNode | FC<{ title: string; type: string; route: string }>
>(...reactNode),
toggleButton: z.boolean()
}),
toc: z.object({
component: z.custom<ReactNode | FC<TOCProps>>(...reactNode),
extraContent: z.custom<ReactNode | FC>(...reactNode),
float: z.boolean(),
title: z.custom<ReactNode | FC>(...reactNode)
}),
useNextSeoProps: z.custom<() => NextSeoProps>(isFunction)
})
.strict()
export const themeSchema = z.strictObject({
banner: z.strictObject({
dismissible: z.boolean(),
key: z.string(),
text: z.custom<ReactNode | FC>(...reactNode).optional()
}),
chat: z.strictObject({
icon: z.custom<ReactNode | FC>(...reactNode),
link: z.string().startsWith('https://').optional()
}),
components: z.record(z.custom<FC>(...fc)).optional(),
darkMode: z.boolean(),
direction: z.enum(['ltr', 'rtl']),
docsRepositoryBase: z.string().startsWith('https://'),
editLink: z.strictObject({
component: z.custom<
FC<{
children: ReactNode
className?: string
filePath?: string
}>
>(...fc),
text: z.custom<ReactNode | FC>(...reactNode)
}),
faviconGlyph: z.string().optional(),
feedback: z.strictObject({
content: z.custom<ReactNode | FC>(...reactNode),
labels: z.string(),
useLink: z.function().returns(z.string())
}),
footer: z.strictObject({
component: z.custom<ReactNode | FC<{ menu: boolean }>>(...reactNode),
text: z.custom<ReactNode | FC>(...reactNode)
}),
gitTimestamp: z.custom<ReactNode | FC<{ timestamp: Date }>>(...reactNode),
head: z.custom<ReactNode | FC>(...reactNode),
i18n: i18nSchema,
logo: z.custom<ReactNode | FC>(...reactNode),
logoLink: z.boolean().or(z.string()),
main: z.custom<FC<{ children: ReactNode }>>(...fc).optional(),
navbar: z.strictObject({
component: z.custom<ReactNode | FC<NavBarProps>>(...reactNode),
extraContent: z.custom<ReactNode | FC>(...reactNode).optional()
}),
navigation: z.boolean().or(
z.strictObject({
next: z.boolean(),
prev: z.boolean()
})
),
nextThemes: z.strictObject({
defaultTheme: z.string(),
forcedTheme: z.string().optional(),
storageKey: z.string()
}),
notFound: z.strictObject({
content: z.custom<ReactNode | FC>(...reactNode),
labels: z.string()
}),
primaryHue: z.number().or(
z.strictObject({
dark: z.number(),
light: z.number()
})
),
project: z.strictObject({
icon: z.custom<ReactNode | FC>(...reactNode),
link: z.string().startsWith('https://').optional()
}),
search: z.strictObject({
component: z.custom<
ReactNode | FC<{ className?: string; directories: Item[] }>
>(...reactNode),
emptyResult: z.custom<ReactNode | FC>(...reactNode),
error: z.string().or(z.function().returns(z.string())),
loading: z.string().or(z.function().returns(z.string())),
// Can't be React component
placeholder: z.string().or(z.function().returns(z.string()))
}),
serverSideError: z.strictObject({
content: z.custom<ReactNode | FC>(...reactNode),
labels: z.string()
}),
sidebar: z.strictObject({
defaultMenuCollapseLevel: z.number().min(1).int(),
titleComponent: z.custom<
ReactNode | FC<{ title: string; type: string; route: string }>
>(...reactNode),
toggleButton: z.boolean()
}),
toc: z.strictObject({
component: z.custom<ReactNode | FC<TOCProps>>(...reactNode),
extraContent: z.custom<ReactNode | FC>(...reactNode),
float: z.boolean(),
title: z.custom<ReactNode | FC>(...reactNode)
}),
useNextSeoProps: z.custom<() => NextSeoProps>(isFunction)
})

const publicThemeSchema = themeSchema.deepPartial().extend({
// to have `locale` and `text` as required properties
Expand Down Expand Up @@ -323,6 +320,23 @@ export const DEEP_OBJECT_KEYS = Object.entries(DEFAULT_THEME)
})
.filter(Boolean)

export const pageThemeSchema = z
.strictObject({
breadcrumb: z.boolean(),
collapsed: z.boolean(),
footer: z.boolean(),
layout: z.enum(['default', 'full', 'raw']),
navbar: z.boolean(),
pagination: z.boolean(),
sidebar: z.boolean(),
timestamp: z.boolean(),
toc: z.boolean(),
typesetting: z.enum(['default', 'article'])
})
.partial()

export type PageTheme = z.infer<typeof pageThemeSchema>

export const DEFAULT_PAGE_THEME: PageTheme = {
breadcrumb: true,
collapsed: false,
Expand Down
56 changes: 39 additions & 17 deletions packages/nextra-theme-docs/src/contexts/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import {
useContext,
useState
} from 'react'
import { PageOpts } from 'nextra'
import { PageOpts, PageMapItem } from 'nextra'
import { ThemeProvider } from 'next-themes'
import { Context } from '../types'
import {
DEEP_OBJECT_KEYS,
DEFAULT_THEME,
DocsThemeConfig,
pageThemeSchema,
themeSchema
} from '../constants'
import { MenuProvider } from './menu'
Expand All @@ -31,8 +32,37 @@ export const useConfig = () => useContext(ConfigContext)
let theme: DocsThemeConfig
let isValidated = process.env.NODE_ENV === 'production'

function lowerCaseFirstLetter(s: string) {
return s.charAt(0).toLowerCase() + s.slice(1)
function normalizeZodMessage(error: unknown): string {
return (error as ZodError).issues
.map(issue => {
const themePath =
issue.path.length > 0 ? `Path: "${issue.path.join('.')}"` : ''
return `${issue.message}. ${themePath}`
})
.join('\n')
}

function validateMeta(pageMap: PageMapItem[]) {
for (const pageMapItem of pageMap) {
if (pageMapItem.kind === 'Meta') {
for (const [key, data] of Object.entries(pageMapItem.data)) {
const hasTheme = data && typeof data === 'object' && 'theme' in data
if (!hasTheme) continue

try {
pageThemeSchema.parse(data.theme)
} catch (error) {
console.error(
`[nextra-theme-docs] Error validating "theme" config in _meta.json file for "${key}" page.\n\n${normalizeZodMessage(
error
)}`
)
}
}
} else if (pageMapItem.kind === 'Folder') {
validateMeta(pageMapItem.children)
}
}
}

export const ConfigProvider = ({
Expand All @@ -58,23 +88,15 @@ export const ConfigProvider = ({
}
if (!isValidated) {
try {
theme = themeSchema.parse(theme, {
errorMap: () => ({ message: 'Invalid theme config' })
})
} catch (err) {
console.error('[nextra] Error validating the theme config')
theme = themeSchema.parse(theme)
} catch (error) {
console.error(
(err as ZodError).issues
.map(
issue =>
'[nextra] Error in config `' +
issue.path.join('.') +
'`: ' +
lowerCaseFirstLetter(issue.message || '')
)
.join('\n')
`[nextra-theme-docs] Error validating theme config file.\n\n${normalizeZodMessage(
error
)}`
)
}
validateMeta(pageOpts.pageMap)
isValidated = true
}
const extendedConfig: Config = {
Expand Down
3 changes: 1 addition & 2 deletions packages/nextra-theme-docs/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ import {
} from './components'
import { getComponents } from './mdx-components'
import { ActiveAnchorProvider, ConfigProvider, useConfig } from './contexts'
import { DEFAULT_LOCALE, PartialDocsThemeConfig } from './constants'
import { DEFAULT_LOCALE, PartialDocsThemeConfig, PageTheme } from './constants'
import { getFSRoute, normalizePages, renderComponent } from './utils'
import { PageTheme } from './types'

function useDirectoryInfo(pageMap: PageMapItem[]) {
const { locale = DEFAULT_LOCALE, defaultLocale, asPath } = useRouter()
Expand Down
13 changes: 0 additions & 13 deletions packages/nextra-theme-docs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,6 @@ import { ReactNode } from 'react'
import { PageOpts } from 'nextra'
import { DocsThemeConfig } from './constants'

export type PageTheme = {
breadcrumb: boolean
collapsed: boolean
footer: boolean
layout: 'default' | 'full' | 'raw'
navbar: boolean
pagination: boolean
sidebar: boolean
timestamp: boolean
toc: boolean
typesetting: 'default' | 'article'
}

export type Context = {
pageOpts: PageOpts
themeConfig: DocsThemeConfig
Expand Down
Loading

0 comments on commit 6a0f428

Please sign in to comment.