Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-cli-diff-ref.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@zpress/cli': minor
---

Add `--ref` option to `zpress diff` for comparing between commits, enabling use as a Vercel `ignoreCommand`
5 changes: 5 additions & 0 deletions .changeset/fix-ui-filetree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@zpress/ui': patch
---

Fix missing FileTree component by pinning rspress-plugin-file-tree to 1.0.3 and copying its component files into the published dist
3 changes: 2 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@
{
"files": ["**/MermaidRenderer.tsx"],
"rules": {
"unicorn/filename-case": "off"
"unicorn/filename-case": "off",
"prefer-destructuring": "off"
}
}
],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"@iconify-json/material-icon-theme": "^1.2.56",
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/pixelarticons": "^1.2.4",
"@iconify-json/simple-icons": "^1.2.74",
"@iconify-json/simple-icons": "^1.2.75",
"@iconify-json/skill-icons": "^1.2.4",
"@iconify-json/vscode-icons": "^1.2.45",
"@microsoft/api-extractor": "^7.57.7",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"@zpress/templates": "workspace:*",
"@zpress/ui": "workspace:*",
"es-toolkit": "catalog:",
"get-port": "^7.1.0",
"get-port": "^7.2.0",
"ts-pattern": "catalog:",
"zod": "catalog:"
},
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ export const buildCommand = command({
quiet: z.boolean().optional().default(false),
clean: z.boolean().optional().default(false),
check: z.boolean().optional().default(true),
verbose: z.boolean().optional().default(false),
}),
handler: async (ctx) => {
const { quiet, check } = ctx.args
const { quiet, check, verbose } = ctx.args
const paths = createPaths(process.cwd())
ctx.logger.intro('zpress build')

Expand Down Expand Up @@ -53,7 +54,7 @@ export const buildCommand = command({
await sync(config, { paths, quiet: true })

ctx.logger.step('Building & checking for broken links...')
const buildResult = await runBuildCheck({ config, paths })
const buildResult = await runBuildCheck({ config, paths, verbose })

const passed = presentResults({ configResult, buildResult, logger: ctx.logger })
if (!passed) {
Expand Down
83 changes: 77 additions & 6 deletions packages/cli/src/commands/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { command } from '@kidd-cli/core'
import type { Section, ZpressConfig, Result } from '@zpress/core'
import { createPaths, hasGlobChars, loadConfig, normalizeInclude } from '@zpress/core'
import { uniq } from 'es-toolkit'
import { match } from 'ts-pattern'
import { z } from 'zod'

const CONFIG_GLOBS = [
Expand All @@ -19,16 +20,42 @@ const CONFIG_GLOBS = [
/**
* Registers the `diff` CLI command to show changed files in watched directories.
*
* By default outputs a space-separated file list to stdout (suitable for
* lefthook, scripts, and piping). Use `--pretty` for human-readable output.
* By default uses `git status` to detect uncommitted changes and outputs a
* space-separated file list to stdout (suitable for lefthook, scripts, and piping).
*
* Use `--ref <ref>` to compare between commits instead of checking working tree
* status. This uses `git diff --name-only <ref> HEAD` under the hood and exits
* with code 1 when changes are detected — matching the Vercel `ignoreCommand`
* convention (exit 1 = proceed with build, exit 0 = skip).
*
* @example
* ```bash
* # Detect uncommitted changes (default, uses git status)
* zpress diff
*
* # Compare against parent commit (CI / Vercel ignoreCommand)
* zpress diff --ref HEAD^
*
* # Compare against main branch (PR context)
* zpress diff --ref main
*
* # Human-readable output
* zpress diff --ref main --pretty
* ```
*/
export const diffCommand = command({
description: 'Show changed files in configured source directories',
options: z.object({
pretty: z.boolean().optional().default(false),
ref: z
.string()
.optional()
.describe(
'Git ref to compare against HEAD (e.g. HEAD^, main). Exits 1 when changes are detected.'
),
}),
handler: async (ctx) => {
const { pretty } = ctx.args
const { pretty, ref } = ctx.args
const paths = createPaths(process.cwd())

const [configErr, config] = await loadConfig(paths.repoRoot)
Expand Down Expand Up @@ -58,7 +85,12 @@ export const diffCommand = command({
return
}

const [gitErr, changed] = gitChangedFiles({ repoRoot: paths.repoRoot, dirs })
const [gitErr, changed] = match(ref)
.when(
(r): r is string => r !== undefined,
(r) => gitDiffFiles({ repoRoot: paths.repoRoot, dirs, ref: r })
)
.otherwise(() => gitChangedFiles({ repoRoot: paths.repoRoot, dirs }))

if (gitErr) {
if (pretty) {
Expand All @@ -83,10 +115,15 @@ export const diffCommand = command({
ctx.logger.step(`Watching ${dirs.length} path(s)`)
ctx.logger.note(changed.join('\n'), `${changed.length} changed file(s)`)
ctx.logger.outro('Done')
return
} else {
process.stdout.write(`${changed.join(' ')}\n`)
}

process.stdout.write(`${changed.join(' ')}\n`)
// When --ref is used (e.g. as a Vercel ignoreCommand), exit 1 signals
// "changes detected, proceed with build". Exit 0 (no changes) means skip.
if (ref) {
process.exit(1)
}
},
})

Expand Down Expand Up @@ -251,6 +288,40 @@ function stripQuotes(value: string): string {
return trimmed
}

/**
* Run `git diff --name-only <ref> HEAD` scoped to the given directories and return changed file paths.
*
* Use this instead of `gitChangedFiles` when comparing between commits (e.g. for
* CI ignore commands like Vercel's `ignoreCommand`), since `git status` only
* detects uncommitted changes and always returns empty on clean checkouts.
*
* @private
* @param params - Parameters for the git diff query
* @param params.repoRoot - Absolute path to the repo root
* @param params.dirs - Directories to scope the git diff to
* @param params.ref - Git ref to compare against HEAD (e.g. `HEAD^`, `main`)
* @returns Result tuple with changed file paths (repo-relative) or an error
*/
function gitDiffFiles(params: {
readonly repoRoot: string
readonly dirs: readonly string[]
readonly ref: string
}): Result<readonly string[]> {
const [err, output] = execSilent({
file: 'git',
args: ['diff', '--name-only', params.ref, 'HEAD', '--', ...params.dirs],
cwd: params.repoRoot,
})
if (err) {
return [err, null]
}
if (!output) {
return [null, []]
}
const files = output.split('\n').filter((line) => line.length > 0)
return [null, files]
}

/**
* Run a command silently with an explicit argument array, returning a Result
* tuple with trimmed stdout on success or an Error on failure.
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/lib/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ interface PresentResultsParams {
interface RunBuildCheckParams {
readonly config: ZpressConfig
readonly paths: Paths
readonly verbose?: boolean
}

interface RunConfigCheckParams {
Expand Down Expand Up @@ -94,6 +95,10 @@ export function runConfigCheck(params: RunConfigCheckParams): ConfigCheckResult
* @returns A `BuildCheckResult` with pass/fail status and any deadlinks found
*/
export async function runBuildCheck(params: RunBuildCheckParams): Promise<BuildCheckResult> {
if (params.verbose) {
return runBuildCheckVerbose(params)
}

const { error, captured } = await captureOutput(() =>
buildSiteForCheck({ config: params.config, paths: params.paths })
)
Expand Down Expand Up @@ -157,6 +162,23 @@ export function presentResults(params: PresentResultsParams): boolean {
// Private
// ---------------------------------------------------------------------------

/**
* Run the build check without capturing output, letting Rspress/Rspack
* write directly to stdout/stderr so the full error details are visible.
*
* @private
* @param params - Config and paths for the build
* @returns A `BuildCheckResult` with pass/fail status
*/
async function runBuildCheckVerbose(params: RunBuildCheckParams): Promise<BuildCheckResult> {
try {
await buildSiteForCheck({ config: params.config, paths: params.paths })
return { status: 'passed' }
} catch (error) {
return { status: 'error', message: toError(error).message }
}
}

/**
* Strip ANSI escape codes from a string.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"gray-matter": "^4.0.3",
"jiti": "^2.6.1",
"js-yaml": "^4.1.1",
"liquidjs": "^10.25.0",
"liquidjs": "^10.25.1",
"ts-pattern": "catalog:"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"@iconify-json/material-icon-theme": "^1.2.56",
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/pixelarticons": "^1.2.4",
"@iconify-json/simple-icons": "^1.2.74",
"@iconify-json/simple-icons": "^1.2.75",
"@iconify-json/skill-icons": "^1.2.4",
"@iconify-json/vscode-icons": "^1.2.45",
"@iconify/react": "^6.0.2",
Expand Down
33 changes: 33 additions & 0 deletions packages/ui/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,19 @@ export default defineConfig({
output: {
target: 'node',
cleanDistPath: true,
// Raw-copied files that Rspress's webpack compiles at runtime as global
// components. These are NOT bundled by Rslib — they must exist as standalone
// files on disk because Rspress injects absolute-path `import` statements
// into compiled MDX and webpack resolves them at build time.
// See: packages/ui/CLAUDE.md for constraints on raw-copied components.
copy: [
// Theme directory: copied as-is so Rspress can resolve it via themeDir config
{
from: './src/theme',
to: 'theme',
},
// MermaidRenderer: raw .tsx global component compiled by Rspress's webpack.
// Must only import packages available in Rspress's webpack context (see CLAUDE.md).
{
from: './src/plugins/mermaid/MermaidRenderer.tsx',
to: 'plugins/mermaid/MermaidRenderer.tsx',
Expand All @@ -38,6 +46,31 @@ export default defineConfig({
from: './src/plugins/mermaid/mermaid.css',
to: 'plugins/mermaid/mermaid.css',
},
// rspress-plugin-file-tree: the plugin registers its FileTree component via
// an absolute path (PACKAGE_ROOT/dist/components/FileTree/FileTree). When
// bundled into our index.mjs, __dirname shifts so the resolved path points
// to packages/ui/dist/components/FileTree/FileTree — so the component files
// must exist here.
{
from: './node_modules/rspress-plugin-file-tree/dist/components',
to: 'components',
},
// 🚨 DANGER: This copies files into the dist root and could clobber our own
// output if the plugin adds new non-chunk files. We exclude index.* and
// components/** to avoid known collisions, but this is brittle — if the
// plugin's dist layout changes, verify nothing gets overwritten.
//
// FileTree's Rslib build code-splits icon/language definitions into chunk
// files (0~*.js) at the dist root. FileTree.js imports them via relative
// paths (../../0~311.js), which resolve from dist/components/FileTree/ to
// dist/. We cannot nest these elsewhere — the relative paths are hardcoded.
{
from: './node_modules/rspress-plugin-file-tree/dist',
to: '',
globOptions: {
ignore: ['**/components/**', '**/index.*'],
},
},
],
},
})
Loading
Loading