Skip to content

Commit

Permalink
Zod (#703)
Browse files Browse the repository at this point in the history
* add new parser that checks type first
* implement better messages
  • Loading branch information
lukasoppermann authored Jul 24, 2023
1 parent 0490bfc commit 3532f60
Show file tree
Hide file tree
Showing 16 changed files with 183 additions and 71 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ module.exports = {
allow: ['path', 'fs', 'fs/promises'],
},
],
'i18n-text/no-en': 0,
},
},
// rules which apply only to Markdown
Expand Down
20 changes: 20 additions & 0 deletions scripts/utilities/walkDir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import fs from 'fs'
import path from 'path'

/**
* Recursively walk a directory and return all file paths
* @param dir directory to search
* @param ignoreDirs directories to ignore
* @returns array of file paths
*/
export const walkDir = (dir: string, ignoreDirs: string[] = []): string[] => {
const files = fs
.readdirSync(dir, {withFileTypes: true})
.flatMap(file => {
if (!file.isDirectory()) return path.join(dir, file.name)
if (!ignoreDirs.includes(file.name)) return walkDir(path.join(dir, file.name), ignoreDirs)
})
.filter(Boolean) as string[]

return files.flat()
}
63 changes: 47 additions & 16 deletions scripts/validateTokenJson.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,11 @@
import {fromZodError} from 'zod-validation-error'
import fs from 'fs'
import path from 'path'
import json5 from 'json5'
import {designToken} from '~/src/schemas/designToken'
import {getFlag} from '../src/utilities/getFlag'
import type {ZodIssue} from 'zod'

const walkDir = (dir: string, ignoreDirs: string[] = []): string[] => {
const files = fs
.readdirSync(dir, {withFileTypes: true})
.flatMap(file => {
if (!file.isDirectory()) return path.join(dir, file.name)
if (!ignoreDirs.includes(file.name)) return walkDir(path.join(dir, file.name), ignoreDirs)
})
.filter(Boolean) as string[]

return files.flat()
}
import {validateType} from '~/src/schemas/validTokenType'
import {walkDir} from './utilities/walkDir'

export const validateTokens = (tokenDir: string) => {
const tokenFiles = walkDir(tokenDir, ['removed', 'fallback'])
Expand All @@ -31,11 +20,37 @@ export const validateTokens = (tokenDir: string) => {
const tokenFile = fs.readFileSync(`${file}`, 'utf8')
try {
const tokenJson = json5.parse(tokenFile)
// validate token $type property
const validateTypes = validateType.safeParse(tokenJson)
if (!validateTypes.success) {
failed.push({
fileName: file,
errorMessage: fromZodError(validateTypes.error, {prefix: '', prefixSeparator: '- '}).message.replace(
/;/g,
'\n-',
),
errors: fromZodError(validateTypes.error).details,
errorsByPath: fromZodError(validateTypes.error).details.reduce((acc, item) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (!acc[item.path.join('.')]) acc[item.path.join('.')] = []
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
acc[item.path.join('.')].push(item)
return acc
}, {}),
})
continue
}
// validate token schema
const validatedTokenJson = designToken.safeParse(tokenJson)
if (validatedTokenJson.success === false) {
failed.push({
fileName: file,
errorMessage: fromZodError(validatedTokenJson.error).message.replace(/;/g, '\n- '),
errorMessage: fromZodError(validatedTokenJson.error, {prefix: '', prefixSeparator: '- '}).message.replace(
/;/g,
'\n-',
),
errors: fromZodError(validatedTokenJson.error).details,
errorsByPath: fromZodError(validatedTokenJson.error).details.reduce((acc, item) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
Expand All @@ -60,8 +75,11 @@ export const validateTokens = (tokenDir: string) => {
}
}

// *****************************************************
// run script
const {failed, files} = validateTokens('./src/tokens/')

// if silent flag is NOT set, output to console
if (getFlag('--silent') === null) {
// eslint-disable-next-line no-console
console.log(`\u001b[36;1m\u001b[1m${files.length} token files validated:\u001b[0m`)
Expand All @@ -82,19 +100,32 @@ if (getFlag('--silent') === null) {
// eslint-disable-next-line no-console
console.log(`\u001b[36;1m\u001b[1m${fail.fileName}\u001b[0m`)
// eslint-disable-next-line no-console
console.log(fail.errorMessage)
console.log(
fail.errorMessage.replace(/\*\*(.*?)\*\*/g, '\u001b[31;1m\u001b[1m$1\u001b[0m').replace(/\n(?!-)/g, '\n ↳ '),
)
// eslint-disable-next-line no-console
console.log('\n\n')
}
}

// if failOnErrors flag is set, exit with error code 1 if any errors were found
// this will fail scripts or set a failed status in CI
if (getFlag('--failOnErrors')) {
if (failed.length > 0) {
process.exit(1)
}
}

// if outFile flag is set, write failed tokens to a json file
if (getFlag('--outFile')) {
// get file name from flag and add .json extension if missing
const filename = `${`${getFlag('--outFile')}`.replace('.json', '')}.json`
fs.writeFileSync(filename, JSON.stringify(failed))
// replace linebreak with <br> for html output
const htmlFailed = failed.map(item => ({
...item,
// eslint-disable-next-line github/unescaped-html-literal
errorsByPath: JSON.parse(JSON.stringify(item.errorsByPath).replace(/\\n/g, '<br />')),
}))
//
fs.writeFileSync(filename, JSON.stringify(htmlFailed))
}
6 changes: 5 additions & 1 deletion src/schemas/alphaValue.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import {z} from 'zod'
import {schemaErrorMessage} from '../utilities/schemaErrorMessage'

export const alphaValue = z.any().refine(
value => typeof value === 'number' && value >= 0 && value <= 1,
value => ({
message: `Invalid alpha value: "${value}" (${typeof value}). Alpha value must be a number between 0 and 1."`,
message: schemaErrorMessage(
`Invalid alpha value: "${value}" (${typeof value})`,
'Alpha value must be a number between 0 and 1.',
),
}),
)
8 changes: 6 additions & 2 deletions src/schemas/collections.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {z} from 'zod'
import {joinFriendly} from '../utilities/joinFriendly'
import {schemaErrorMessage} from '../utilities/schemaErrorMessage'

type Collections =
| 'base/color/light'
Expand All @@ -14,7 +15,10 @@ export const collection = (collections: Collections[]) => {
return z.string().refine(
value => collections.includes(value as Collections),
value => ({
message: `Invalid collection: "${value}", valid collections are ${joinFriendly(collections)}`,
message: schemaErrorMessage(
`Invalid collection: "${value}"`,
`Valid collections are ${joinFriendly(collections)}`,
),
}),
)
}
Expand All @@ -25,7 +29,7 @@ export const mode = (modes: Modes[]) => {
return z.string().refine(
value => modes.includes(value as Modes),
value => ({
message: `Invalid mode: "${value}", valid modes are ${joinFriendly(modes)}`,
message: schemaErrorMessage(`Invalid mode: "${value}"`, `Valid modes are ${joinFriendly(modes)}`),
}),
)
}
13 changes: 10 additions & 3 deletions src/schemas/colorHexValue.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import {z} from 'zod'
import {schemaErrorMessage} from '../utilities/schemaErrorMessage'

const colorHex3RegEx = '^#[0-9a-f]{3}$'
const colorHex6RegEx = '^#[0-9a-f]{6}$'
const colorHex8RegEx = '^#[0-9a-f]{8}$'

const colorHexRegex = new RegExp(`(${colorHex3RegEx})|(${colorHex6RegEx})|(${colorHex8RegEx})`, 'i')

export const colorHexValue = z
.string()
.regex(colorHexRegex, {message: 'Invalid color: Color must be a hex string or a reference to a color token.'})
export const colorHexValue = z.string().refine(
color => colorHexRegex.test(color),
color => ({
message: schemaErrorMessage(
`Invalid color: ${color}`,
'Color must be a hex string or a reference to a color token.',
),
}),
)
20 changes: 1 addition & 19 deletions src/schemas/designToken.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {z} from 'zod'
import {tokenName} from './tokenName'
import {referenceToken} from './referenceToken'
import {stringToken} from './stringToken'
import {viewportRangeToken} from './viewportRangeToken'
import {numberToken} from './numberToken'
Expand Down Expand Up @@ -30,25 +29,8 @@ export const designToken = z.record(
numberToken,
stringToken,
]),
referenceToken,
// referenceToken,
designToken,
])
}),
)

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: TODO: fix this
// export const addType = z.record(
// tokenName,
// z.lazy(() => {
// return z.union([
// z
// .object({
// $value: z.any(),
// $type: z.string().default('reference'),
// })
// .required(),
// addType,
// ])
// }),
// )
13 changes: 10 additions & 3 deletions src/schemas/dimensionValue.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import {z} from 'zod'
import {schemaErrorMessage} from '../utilities/schemaErrorMessage'

export const dimensionValue = z.union([
z.string().regex(/(^-?[0-9]+(px|rem)$|^-?[0-9]+\.?[0-9]*em$)/, {
message: `Dimension must be a string with a unit (px, rem or em) or 0`,
}),
z.string().refine(
dim => /(^-?[0-9]+(px|rem)$|^-?[0-9]+\.?[0-9]*em$)/.test(dim),
val => ({
message: schemaErrorMessage(
`Invalid dimension: "${val}"`,
`Dimension must be a string with a unit (px, rem or em) or 0`,
),
}),
),
z.literal('0'),
z.literal(0),
])
8 changes: 7 additions & 1 deletion src/schemas/fontWeightValue.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import {z} from 'zod'
import {schemaErrorMessage} from '../utilities/schemaErrorMessage'

const allowed = [100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
export const fontWeightValue = z.number().refine(
value => allowed.includes(value),
value => ({message: `Invalid font weight value "${value}", must be one of ${allowed.join(', ')}`}),
value => ({
message: schemaErrorMessage(
`Invalid font weight value: "${value}"`,
`Font weight must be one of ${allowed.join(', ')}`,
),
}),
)
9 changes: 0 additions & 9 deletions src/schemas/referenceToken.ts

This file was deleted.

11 changes: 10 additions & 1 deletion src/schemas/referenceValue.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import {z} from 'zod'
import {schemaErrorMessage} from '../utilities/schemaErrorMessage'

export const referenceValue = z.string().regex(/^{\w+\.(\w+\.)*\w+}$/)
export const referenceValue = z.string().refine(
ref => /^{\w+\.(\w+\.)*\w+}$/.test(ref),
ref => ({
message: schemaErrorMessage(
`Invalid reference: "${ref}"`,
'Reference must be a string in the format "{path.to.token}".',
),
}),
)
27 changes: 27 additions & 0 deletions src/schemas/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {designToken} from './designToken'

describe('Schema validation', () => {
const validTokenJson = {
parent: {
color: {
$value: '#000000',
$type: 'color',
},
},
}

it('returns success on valid schema', () => {
const parsedToken = designToken.safeParse(validTokenJson)
expect(parsedToken.success).toStrictEqual(true)
})

it('returns success false on invalid schema', () => {
const parsedToken = designToken.safeParse({
color: {
$value: '#000000',
$type: 'colors',
},
})
expect(parsedToken.success).toStrictEqual(false)
})
})
3 changes: 2 additions & 1 deletion src/schemas/scopes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {z} from 'zod'
import {joinFriendly} from '../utilities/joinFriendly'
import {schemaErrorMessage} from '../utilities/schemaErrorMessage'

type ValidScope = 'all' | 'bgColor' | 'fgColor' | 'borderColor' | 'size' | 'gap' | 'radius' | 'borderColor'
const validScopes: ValidScope[] = ['all', 'bgColor', 'fgColor', 'borderColor', 'size', 'gap', 'radius', 'borderColor']
Expand All @@ -9,7 +10,7 @@ export const scopes = (scopeSubset?: ValidScope[]) => {
return z.array(z.string()).refine(
value => value.every(item => scopeArray.includes(item as ValidScope)),
value => ({
message: `Invalid scope "${value}", valid scopes are: ${joinFriendly(scopeArray)}`,
message: schemaErrorMessage(`Invalid scope: "${value}"`, `Valid scopes are: ${joinFriendly(scopeArray)}`),
}),
)
}
14 changes: 10 additions & 4 deletions src/schemas/tokenName.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import {z} from 'zod'
import {schemaErrorMessage} from '../utilities/schemaErrorMessage'

export const tokenName = z.string().regex(/^[a-z0-9][A-Za-z0-9-]*$/, {
message:
'Invalid token name: Token name must be kebab-case or camelCase, and start with a lowercase letter or number and consist only of letters, numbers, and hyphens.',
})
export const tokenName = z.string().refine(
name => /^[a-z0-9][A-Za-z0-9-]*$/.test(name),
name => ({
message: schemaErrorMessage(
`Invalid token name: "${name}"`,
'Token name must be kebab-case or camelCase, and start with a lowercase letter or number and consist only of letters, numbers, and hyphens.',
),
}),
)
Loading

0 comments on commit 3532f60

Please sign in to comment.