Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
"explorer.fileNesting.enabled": true,
"files.exclude": {
"**/node_modules": true
},
Expand Down
95 changes: 79 additions & 16 deletions docs/guide/custom-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,18 @@ export const title = defineSchema(() => s.string().min(1).max(100))
// for validating email
export const email = defineSchema(() => s.string().email({ message: 'Invalid email address' }))

// custom validation logic
export const hello = defineSchema(() =>
s.string().refine(value => {
if (value !== 'hello') {
return 'Value must be "hello"'
// custom validation logic using refine
export const hello = defineSchema(() => s.string().refine(value => value === 'hello', 'Value must be "hello"'))

// custom validation logic using superRefine (for more control)
export const customValidation = defineSchema(() =>
s.string().superRefine((value, ctx) => {
if (value.length < 5) {
ctx.addIssue({ code: 'custom', message: 'Value must be at least 5 characters' })
}
if (!value.includes('@')) {
ctx.addIssue({ code: 'custom', message: 'Value must contain @ symbol' })
}
return true
})
)
```
Expand All @@ -40,10 +45,28 @@ Refer to [Zod documentation](https://zod.dev) for more information about Zod.
```ts
import { defineSchema, s } from 'velite'

// for transforming title
// for transforming title (simple transform)
export const title = defineSchema(() => s.string().transform(value => value.toUpperCase()))

// ...
// for transforming with error handling (using ctx.addIssue)
export const safeTransform = defineSchema(() =>
s.string().transform((value, ctx) => {
try {
return value.toUpperCase()
} catch (err) {
ctx.addIssue({ fatal: true, code: 'custom', message: 'Transform failed' })
return value
}
})
)

// async transform (zod 4 supports async transforms)
export const asyncTransform = defineSchema(() =>
s.string().transform(async (value, ctx) => {
// async operations...
return processedValue
})
)
```

### Example
Expand All @@ -59,7 +82,7 @@ import type { Image } from 'velite'
* Remote Image with metadata schema
*/
export const remoteImage = () =>
s.string().transform<Image>(async (value, { addIssue }) => {
s.string().transform<Image>(async (value, ctx) => {
try {
const response = await fetch(value)
const blob = await response.blob()
Expand All @@ -69,7 +92,7 @@ export const remoteImage = () =>
return { src: value, ...metadata }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
addIssue({ fatal: true, code: 'custom', message })
ctx.addIssue({ fatal: true, code: 'custom', message })
return null as never
}
})
Expand All @@ -78,22 +101,62 @@ export const remoteImage = () =>
## Schema Context

> [!TIP]
> Considering that Velite's scenario often needs to obtain metadata information about the current file in the schema, Velite does not use the original Zod package. Instead, it uses a custom Zod package that provides a `meta` member in the schema context.
> In Zod 4, the context object (`ctx`) in `refine`, `superRefine`, and `transform` provides an `addIssue()` method for adding validation errors. Velite extends this context to provide access to file metadata through `context()` function.

### Using Context API

```ts
import { defineSchema, s } from 'velite'
import { context, defineSchema, s } from 'velite'

// convert a nonexistent field
// Access file context in transform
export const path = defineSchema(() =>
s.custom<string>().transform((value, ctx) => {
if (ctx.meta.path) {
return ctx.meta.path
// Use context() to access current file information
const { file, config } = context()

if (value == null) {
// Use ctx.addIssue() to add validation errors (Zod 4 API)
ctx.addIssue({ fatal: false, code: 'custom', message: 'Using file path as fallback' })
return file.path
}
return value
})
)
```

### Context API Reference

The `context()` function returns an object with:

- `config`: The resolved Velite configuration
- `file`: The current [`VeliteFile`](../reference/types.md#velitefile) being processed

### Error Handling in Transforms

```ts
import { defineSchema, s } from 'velite'

export const safeTransform = defineSchema(() =>
s.string().transform(async (value, ctx) => {
try {
// async operation
const result = await processValue(value)
return result
} catch (err) {
// Add error issue using Zod 4 API
ctx.addIssue({
fatal: true, // Set to true to stop processing
code: 'custom', // Error code
message: err.message // Error message
})
return null as never // Type assertion for TypeScript
}
})
)
```

### Reference

the type of `meta` is `ZodMeta`, which extends [`VeliteFile`](../reference/types.md#velitefile).
- `context()` returns `{ config: Config, file: VeliteFile }`
- `ctx.addIssue()` accepts `{ fatal?: boolean, code: string, message: string }`
- See [`VeliteFile`](../reference/types.md#velitefile) for file metadata structure
36 changes: 25 additions & 11 deletions docs/guide/define-collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,35 +156,49 @@ const posts = defineCollection({

### Transform Context Metadata

The `transform()` function can receive a second argument, which is the context object. This is useful for adding computed fields to the content items in a collection.
The `transform()` function can receive a second argument, which is the context object (in Zod 4). To access file metadata, use the `context()` function from Velite.

```js
import { context, s } from 'velite'

const posts = defineCollection({
schema: s
.object({
// fields
})
.transform((data, { meta }) => ({
...data,
// computed fields
path: meta.path // or parse to filename based slug
}))
.transform(data => {
const { file } = context()
return {
...data,
// computed fields
path: file.path, // or parse to filename based slug
basename: file.basename
}
})
})
```

the type of `meta` is `ZodMeta`, which extends [`VeliteFile`](../reference/types.md#velitefile). for more information, see [Custom Schema](custom-schema.md).
For more information about accessing file context, see [Custom Schema](custom-schema.md).

## Content Body

Velite's built-in loader keeps content's raw body in `meta.content`, and the plain text body in `meta.plain`.
Velite's built-in loader keeps content's raw body in `file.content`, and the plain text body in `file.plain`.

To add them as a field, you can use a custom schema.
To add them as a field, you can use a custom schema with the `context()` function.

```js
import { context, s } from 'velite'

const posts = defineCollection({
schema: s.object({
content: s.custom().transform((data, { meta }) => meta.content),
plain: s.custom().transform((data, { meta }) => meta.plain)
content: s.custom().transform(() => {
const { file } = context()
return file.content
}),
plain: s.custom().transform(() => {
const { file } = context()
return file.plain
})
})
})
```
Expand Down
18 changes: 10 additions & 8 deletions docs/guide/last-modified.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,18 @@ Create a timestamp schema based on file stat.

```ts
import { stat } from 'fs/promises'
import { defineSchema } from 'velite'
import { context, defineSchema } from 'velite'

const timestamp = defineSchema(() =>
s
.custom<string | undefined>(i => i === undefined || typeof i === 'string')
.transform<string>(async (value, { meta, addIssue }) => {
.transform<string>(async (value, ctx) => {
if (value != null) {
addIssue({ fatal: false, code: 'custom', message: '`s.timestamp()` schema will resolve the file modified timestamp' })
ctx.addIssue({ fatal: false, code: 'custom', message: '`s.timestamp()` schema will resolve the file modified timestamp' })
}

const stats = await stat(meta.path)
const { file } = context()
const stats = await stat(file.path)
return stats.mtime.toISOString()
})
)
Expand All @@ -45,18 +46,19 @@ const posts = defineCollection({
```ts
import { exec } from 'child_process'
import { promisify } from 'util'
import { defineSchema } from 'velite'
import { context, defineSchema } from 'velite'

const execAsync = promisify(exec)

const timestamp = defineSchema(() =>
s
.custom<string | undefined>(i => i === undefined || typeof i === 'string')
.transform<string>(async (value, { meta, addIssue }) => {
.transform<string>(async (value, ctx) => {
if (value != null) {
addIssue({ fatal: false, code: 'custom', message: '`s.timestamp()` schema will resolve the value from `git log -1 --format=%cd`' })
ctx.addIssue({ fatal: false, code: 'custom', message: '`s.timestamp()` schema will resolve the value from `git log -1 --format=%cd`' })
}
const { stdout } = await execAsync(`git log -1 --format=%cd ${meta.path}`)
const { file } = context()
const { stdout } = await execAsync(`git log -1 --format=%cd ${file.path}`)
return new Date(stdout || Date.now()).toISOString()
})
)
Expand Down
11 changes: 6 additions & 5 deletions docs/guide/using-mdx.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,11 @@ const compileMdx = async (source: string, path: string, options: CompileOptions)
}

export const mdxBundle = (options: MdxOptions = {}) =>
custom<string>().transform<string>(async (value, { meta: { path, content, config }, addIssue }) => {
value = value ?? content
custom<string>().transform<string>(async (value, ctx) => {
const { file, config } = context()
value = value ?? file.content
if (value == null) {
addIssue({ fatal: true, code: 'custom', message: 'The content is empty' })
ctx.addIssue({ fatal: true, code: 'custom', message: 'The content is empty' })
return null as never
}

Expand All @@ -339,9 +340,9 @@ export const mdxBundle = (options: MdxOptions = {}) =>
const compilerOptions = { ...config.mdx, ...options, outputFormat, remarkPlugins, rehypePlugins }

try {
return await compileMdx(value, path, compilerOptions)
return await compileMdx(value, file.path, compilerOptions)
} catch (err: any) {
addIssue({ fatal: true, code: 'custom', message: err.message })
ctx.addIssue({ fatal: true, code: 'custom', message: err.message })
return null as never
}
})
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/velite-schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ name: s.unique('taxonomies')
// 'foo' => 'foo'

// case 2. non-unique value (in all unique by 'taxonomies')
// 'foo' => issue 'Already exists'
// 'foo' => issue 'Duplicate 'foo' with '/path/to/existing/file.yml''
```

### Parameters
Expand Down
27 changes: 15 additions & 12 deletions docs/other/snippets.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import { defineSchema } from 'velite'
const timestamp = defineSchema(() =>
s
.custom<string | undefined>(i => i === undefined || typeof i === 'string')
.transform<string>(async (value, { meta, addIssue }) => {
.transform<string>(async (value, ctx) => {
if (value != null) {
addIssue({ fatal: false, code: 'custom', message: '`s.timestamp()` schema will resolve the file modified timestamp' })
ctx.addIssue({ fatal: false, code: 'custom', message: '`s.timestamp()` schema will resolve the file modified timestamp' })
}

const stats = await stat(meta.path)
const { file } = context()
const stats = await stat(file.path)
return stats.mtime.toISOString()
})
)
Expand All @@ -43,11 +44,12 @@ const execAsync = promisify(exec)
const timestamp = defineSchema(() =>
s
.custom<string | undefined>(i => i === undefined || typeof i === 'string')
.transform<string>(async (value, { meta, addIssue }) => {
.transform<string>(async (value, ctx) => {
if (value != null) {
addIssue({ fatal: false, code: 'custom', message: '`s.timestamp()` schema will resolve the value from `git log -1 --format=%cd`' })
ctx.addIssue({ fatal: false, code: 'custom', message: '`s.timestamp()` schema will resolve the value from `git log -1 --format=%cd`' })
}
const { stdout } = await execAsync(`git log -1 --format=%cd ${meta.path}`)
const { file } = context()
const { stdout } = await execAsync(`git log -1 --format=%cd ${file.path}`)
return new Date(stdout || Date.now()).toISOString()
})
)
Expand All @@ -73,7 +75,7 @@ import type { Image } from 'velite'
* Remote Image with metadata schema
*/
export const remoteImage = () =>
s.string().transform<Image>(async (value, { addIssue }) => {
s.string().transform<Image>(async (value, ctx) => {
try {
const response = await fetch(value)
const blob = await response.blob()
Expand All @@ -83,7 +85,7 @@ export const remoteImage = () =>
return { src: value, ...metadata }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
addIssue({ fatal: true, code: 'custom', message })
ctx.addIssue({ fatal: true, code: 'custom', message })
return null as never
}
})
Expand Down Expand Up @@ -273,16 +275,17 @@ export interface ExcerptOptions {
}

export const excerpt = ({ separator = 'more', length = 300 }: ExcerptOptions = {}) =>
custom<string>().transform(async (value, { meta: { path, content, config } }) => {
if (value == null && content != null) {
value = content
custom<string>().transform(async (value, ctx) => {
const { file, config } = context()
if (value == null && file.content != null) {
value = file.content
}
try {
const mdast = fromMarkdown(value)
const hast = raw(toHast(mdast, { allowDangerousHtml: true }))
const exHast = hastExcerpt(hast, { comment: separator, maxSearchSize: 1024 })
const output = exHast ?? truncate(hast, { size: length, ellipsis: '…' })
await rehypeCopyLinkedFiles(config.output)(output, { path })
await rehypeCopyLinkedFiles(config.output)(output, { path: file.path })
return toHtml(output)
} catch (err: any) {
ctx.addIssue({ fatal: true, code: 'custom', message: err.message })
Expand Down
Loading