Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
14 changes: 7 additions & 7 deletions examples/basic/velite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { defineConfig, s } from 'velite'
import { context, defineConfig, s } from 'velite'

const slugify = input =>
input
Expand All @@ -25,11 +25,11 @@ const execAsync = promisify(exec)
const timestamp = () =>
s
.custom(i => i === undefined || typeof i === 'string')
.transform(async (value, { meta, addIssue }) => {
.transform(async (value, { addIssue }) => {
if (value != null) {
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 { stdout } = await execAsync(`git log -1 --format=%cd ${context().file.path}`)
return new Date(stdout || Date.now()).toISOString()
})

Expand Down Expand Up @@ -63,7 +63,7 @@ export default defineConfig({
schema: s
.object({
name: s.unique('categories'),
slug: s.slug('global'),
slug: s.slug(),
cover: s.image().optional(),
description: s.string().max(999).optional(),
count
Expand All @@ -76,7 +76,7 @@ export default defineConfig({
schema: s
.object({
name: s.string().max(20),
slug: s.slug('global'),
slug: s.slug(),
cover: s.image().optional(),
description: s.string().max(999).optional(),
count
Expand All @@ -89,11 +89,11 @@ export default defineConfig({
schema: s
.object({
title: s.string().max(99),
slug: s.slug('global'),
slug: s.slug(),
body: s.mdx(),
raw: s.raw()
})
.transform((data, { meta }) => ({ ...data, permalink: `/${data.slug}`, basename: meta.basename }))
.transform(data => ({ ...data, permalink: `/${data.slug}`, basename: context().file.basename }))
},
posts: {
name: 'Post',
Expand Down
15 changes: 2 additions & 13 deletions examples/nextjs/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,6 @@
import type { NextConfig } from 'next'
import { withVelite } from '@velite/plugin-next'

const isDev = process.argv.indexOf('dev') !== -1
const isBuild = process.argv.indexOf('build') !== -1
if (!process.env.VELITE_STARTED && (isDev || isBuild)) {
process.env.VELITE_STARTED = '1'
import('velite').then(m => m.build({ watch: isDev, clean: !isDev }))
}

const nextConfig: NextConfig = {
/* config options here */
}

export default nextConfig
export default withVelite()

// legacy next.config.js ↓ (not support turbopack)

Expand Down
19 changes: 10 additions & 9 deletions examples/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,21 @@
"content": "velite --clean"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^24.3.0",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^25.0.2",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"next": "16.0.7",
"@velite/plugin-next": "workspace:*",
"next": "16.0.10",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"react": "19.2.1",
"react-dom": "19.2.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"rehype-pretty-code": "^0.14.1",
"shiki": "^3.11.0",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2",
"shiki": "^3.20.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"velite": "workspace:*"
}
}
6 changes: 3 additions & 3 deletions examples/nextjs/velite.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import rehypePrettyCode from 'rehype-pretty-code'
import { defineCollection, defineConfig, s } from 'velite'
import { context, defineCollection, defineConfig, s } from 'velite'

const slugify = (input: string) =>
input
Expand All @@ -26,11 +26,11 @@ const execAsync = promisify(exec)
const timestamp = () =>
s
.custom<string | undefined>(i => i === undefined || typeof i === 'string')
.transform<string>(async (value, { meta, addIssue }) => {
.transform<string>(async (value, { addIssue }) => {
if (value != null) {
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 { stdout } = await execAsync(`git log -1 --format=%cd ${context().file.path}`)
return new Date(stdout || Date.now()).toISOString()
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same command injection risk as in the Vite example.

This timestamp() helper has the same vulnerability where the file path is passed directly to a shell command without escaping.

Apply the same fix using execFile instead of exec:

+import { execFile } from 'node:child_process'
+
+const execFileAsync = promisify(execFile)
+
 const timestamp = () =>
   s
     .custom<string | undefined>(i => i === undefined || typeof i === 'string')
     .transform<string>(async (value, { addIssue }) => {
       if (value != null) {
         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 ${context().file.path}`)
+      const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%cd', context().file.path])
       return new Date(stdout || Date.now()).toISOString()
     })

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In examples/nextjs/velite.config.ts around lines 29 to 35, the transform uses
execAsync with a file path interpolated into a shell command causing a command
injection risk; replace this with a safe execFile-based call (or promisified
execFile) that passes the git and format args plus context().file.path as an
argument (do not interpolate into a shell string), await the execFile result,
and return the ISO timestamp from stdout (falling back to Date.now() if stdout
is empty); ensure any error is handled similarly to current behavior and that
addIssue logic remains unchanged.


Expand Down
28 changes: 14 additions & 14 deletions examples/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,24 @@
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7",
"@eslint/js": "^9.39.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@velite/plugin-vite": "workspace:*",
"@vitejs/plugin-react": "^5.0.1",
"eslint": "^9.34.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.25",
"globals": "^16.5.0",
"rehype-pretty-code": "^0.14.1",
"shiki": "^3.11.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.40.0",
"shiki": "^3.20.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.49.0",
"velite": "workspace:*",
"vite": "^7.1.3"
"vite": "^7.2.7"
}
}
6 changes: 3 additions & 3 deletions examples/vite/velite.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import rehypePrettyCode from 'rehype-pretty-code'
import { defineCollection, defineConfig, s } from 'velite'
import { context, defineCollection, defineConfig, s } from 'velite'

const slugify = (input: string) =>
input
Expand All @@ -26,11 +26,11 @@ const execAsync = promisify(exec)
const timestamp = () =>
s
.custom<string | undefined>(i => i === undefined || typeof i === 'string')
.transform<string>(async (value, { meta, addIssue }) => {
.transform<string>(async (value, { addIssue }) => {
if (value != null) {
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 { stdout } = await execAsync(`git log -1 --format=%cd ${context().file.path}`)
return new Date(stdout || Date.now()).toISOString()
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential command injection via unescaped file path.

The file path is interpolated directly into a shell command without escaping. If a file path contains shell metacharacters (spaces, quotes, $, ;, etc.), it could cause command failures or injection vulnerabilities.

Consider escaping the path or using execFile which doesn't invoke a shell:

+import { execFile } from 'node:child_process'
+
+const execFileAsync = promisify(execFile)
+
 const timestamp = () =>
   s
     .custom<string | undefined>(i => i === undefined || typeof i === 'string')
     .transform<string>(async (value, { addIssue }) => {
       if (value != null) {
         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 ${context().file.path}`)
+      const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%cd', context().file.path])
       return new Date(stdout || Date.now()).toISOString()
     })

Committable suggestion skipped: line range outside the PR's diff.


Expand Down
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,18 @@
},
"dependencies": {
"@mdx-js/mdx": "^3.1.1",
"esbuild": "^0.25.12",
"esbuild": "^0.27.1",
"sharp": "^0.34.5",
"terser": "^5.44.1"
"terser": "^5.44.1",
"zod": "^4.1.13"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/node": "^22.19.1",
"@types/node": "^25.0.2",
"@types/picomatch": "^4.0.2",
"chokidar": "^4.0.3",
"chokidar": "^5.0.0",
"fast-glob": "^3.3.3",
"hast-util-raw": "^9.1.0",
"hast-util-to-string": "^3.0.1",
Expand Down
19 changes: 19 additions & 0 deletions packages/next/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { NextConfig } from 'next'
import type { Options as VeliteOptions } from 'velite'

export type Options = Omit<VeliteOptions, 'watch' | 'clean'>

type NextConfigFunction = (phase: string, options: { defaultConfig: NextConfig }) => Promise<NextConfig> | NextConfig
type NextConfigInput = NextConfig | NextConfigFunction

/**
* Create a Next.js plugin for integrating Velite
*/
declare const createNextPlugin: (options?: Options) => <T extends NextConfigInput>(nextConfig?: T) => Promise<T>

/**
* Next.js plugin for integrating Velite
*/
declare const withVelite: ReturnType<typeof createNextPlugin>
Comment on lines +12 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat packages/next/index.js | head -80

Repository: zce/velite

Length of output: 1673


🏁 Script executed:

cat packages/next/index.d.ts

Repository: zce/velite

Length of output: 722


Correct the type signature to reflect actual behavior.

The generic <T extends NextConfigInput> with return type Promise<T> creates a type mismatch when T is NextConfigFunction. The implementation returns nextConfig as-is without invoking it, which means if a function-style config is passed, the return type Promise<NextConfigFunction> represents a Promise resolving to a function—not a valid Next.js config. Either restrict the generic to exclude NextConfigFunction, or invoke the function before returning to ensure the resolved value is always a plain NextConfig object.

🤖 Prompt for AI Agents
In packages/next/index.d.ts around lines 12 to 17, the exported createNextPlugin
signature allows T to be NextConfigFunction but returns Promise<T>, which would
make the Promise resolve to a function (invalid as a Next.js config); fix by
narrowing the generic so T excludes NextConfigFunction (e.g. T extends
NextConfigInput but not a function) or change the implementation/signature to
detect and call a function-style nextConfig and return Promise<NextConfig> (i.e.
always resolve to a plain NextConfig object) — pick one approach and update the
type declaration accordingly so the declared return type matches actual runtime
behavior.


export { createNextPlugin, withVelite, type Options }
50 changes: 50 additions & 0 deletions packages/next/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Thanks to `https://github.com/sdorra/content-collections` for Next.js v16+ Velite integration.
*/

/**
* Create a Next.js plugin for integrating Velite
* @param {Omit<import('velite').Options, 'watch' | 'clean'>} pluginOptions
* @returns {import('next').NextConfig} Next.js plugin
*/
export const createNextPlugin = (pluginOptions = {}) => {
const [command] = process.argv.slice(2).filter(i => !i.startsWith('-'))

// typegen loads next.config.js
const isTypegen = command === 'typegen'

// the build step loads next.config.js
const isBuild = command === 'build'

// starting with v16 next dev doesn't load next.config.js
// next dev - calls next-start in a forked process
// next-start loads next.config.js
// process.argv are not visible by next-start
const isDev =
// to make this compatible with previous versions
// check if command is NOT set (next-start) and we are in development mode
typeof command === 'undefined' && process.env.NODE_ENV === 'development'

/**
* @param {import('next').NextConfig} nextConfig
* @returns {Promise<import('next').NextConfig>}
*/
return async (nextConfig = {}) => {
// prevent multiple calls
if (process.env.__VELITE_STARTED) return nextConfig

// if not dev, build, or typegen, return the next config
if (!isDev && !isBuild && !isTypegen) return nextConfig

// start velite
process.env.__VELITE_STARTED = '1'

const { build } = await import('velite')

await build({ ...pluginOptions, watch: isDev, clean: !isDev })

return nextConfig
}
}

export const withVelite = createNextPlugin()
24 changes: 24 additions & 0 deletions packages/next/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@velite/plugin-next",
"version": "0.0.1",
"description": "Next.js plugin for integrating Velite",
"type": "module",
"exports": "./index.js",
"types": "./index.d.ts",
"files": [
"index.js",
"index.d.ts"
],
"keywords": [
"next-plugin",
"velite",
"content"
],
"peerDependencies": {
"next": "^16.0.0",
"velite": "workspace:*"
},
"publishConfig": {
"access": "public"
}
}
24 changes: 24 additions & 0 deletions packages/next/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# @velite/plugin-next

A Next.js plugin for integrating Velite content processing.

## Installation

```bash
npm install -D velite @velite/plugin-next
```

## Usage

```ts
// next.config.ts
import { withVelite } from '@velite/plugin-next'

export default withVelite({
// other next config here...
})
```

## License

[MIT](../../license) &copy; [zce](https://zce.me)
Comment on lines +1 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Verify license link path/casing

README content looks good. Please double-check that ../../license matches the actual filename in the repo (often LICENSE), otherwise this link may 404 on case‑sensitive paths.

🤖 Prompt for AI Agents
In packages/next/readme.md around lines 1 to 24, the README links to
../../license which may not match the repository filename/casing (commonly
LICENSE) and can 404 on case-sensitive filesystems; update the link to the exact
filename and path used in the repo (for example ../../LICENSE) or use an
absolute/root-relative link to the repo license, then verify the link works in a
case-sensitive environment.

Loading