diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 00000000..087efd6f --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,54 @@ +# `.agents/` — AI Workspace + +This directory is the central hub for AI-related artefacts for this repository. It is **not** part of the shipped codebase. + +## Two directories, two purposes + +| Directory | Purpose | Lifetime | Tracked in git? | +| ------------ | ---------------------------------------------------------- | ------------------ | ------------------- | +| `knowledge/` | Stable conventions every session (human + AI) must follow | Long-lived, edited | **Yes** | +| `sessions/` | Transient session artefacts: plans, scratch notes, reports | Per-session | **No** (gitignored) | + +If a finding is universal and reusable → it belongs in `knowledge/`. +If it is "what we did this session" or "where we are right now" → it belongs in `sessions/`. + +## Layout + +``` +.agents/ +├── AGENTS.md ← this file +├── knowledge/ ← stable, long-lived AI conventions (tracked) +│ └── ... +└── sessions/ ← transient per-session scratch (gitignored) + ├── .gitignore ← ignores session subfolders, tracks AGENTS.md + ├── AGENTS.md ← session folder rules & naming + └── YYYYMMDD-HHmm-{slug}/ ← one folder per session + ├── plan.md + ├── notes.md + ├── report.md + └── ... +``` + +## Git policy + +- `knowledge/` is **tracked** — commit conventions here like any other source file. +- `sessions/.gitignore` ignores all session subfolders (`*/`). To commit a session on a feature branch for review context, add an un-ignore line (e.g. `!20260617-1430-schema-refactor`). Remove it before merging to `main`. +- Session folders must **never** be merged into `main`. Promote durable findings to `knowledge/` before cleanup. + +## When to create a session + +**Create a new session** when the user starts a new, unrelated task or a previous session is finished. + +**Reuse the existing session** when the user says "continue", "keep going", or the work spans multiple conversations on the same task. + +When unsure, list recent sessions (`ls -1t sessions/`) and ask. + +## Knowledge directory + +`knowledge/` holds conventions discovered during development that future sessions should follow. Examples: + +- Naming conventions specific to this repo +- Non-obvious build or test patterns +- Decisions with rationale that aren't obvious from the code + +Each file should be short, factual, and named by topic (e.g. `schema-patterns.md`, `testing-notes.md`). diff --git a/.agents/knowledge/.gitkeep b/.agents/knowledge/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.agents/sessions/.gitignore b/.agents/sessions/.gitignore new file mode 100644 index 00000000..d6b2b0b7 --- /dev/null +++ b/.agents/sessions/.gitignore @@ -0,0 +1,6 @@ +# AI Agents sessions — only AGENTS.md is committed; session subfolders are ignored +*/ + +# You can temporarily include the current session directory in your branch for a better DX +# Remove this before merging, or it will cause noise in master +# e.g. `!20260427-1200-schema-refactor` diff --git a/.agents/sessions/AGENTS.md b/.agents/sessions/AGENTS.md new file mode 100644 index 00000000..03394145 --- /dev/null +++ b/.agents/sessions/AGENTS.md @@ -0,0 +1,114 @@ +# `.agents/sessions/` — AI Session Workspace + +Transient scratch space for AI-assisted sessions: plans, research notes, status reports, and any artefact that helps a human or a future AI session pick up where the previous one left off. + +**Not** part of the codebase. **Not** for source code. + +--- + +## Git policy + +`sessions/.gitignore` ignores all session subfolders by default — only `sessions/AGENTS.md` is tracked. Session subfolders are ignored so casual `git add .` never sweeps them up. + +### Opting a session into a feature branch (recommended) + +Session artefacts are valuable review context. To commit a session folder on a feature branch, add an un-ignore line to `sessions/.gitignore`: + +``` +!20260617-1430-schema-refactor +``` + +### Hard rule: do NOT merge session content into main + +Before merging: + +1. Remove the `!` un-ignore line from `sessions/.gitignore`. +2. `git rm -r --cached .agents/sessions//` to drop from index. +3. Commit the cleanup, then merge. + +If a session artefact has long-term value, **promote it** to `../knowledge/` (or another appropriate location) before the cleanup commit. + +--- + +## Layout + +``` +.agents/sessions/ +├── .gitignore ← this file: ignores session subfolders +├── AGENTS.md ← session folder rules & naming (this file) +└── YYYYMMDD-HHmm-{slug}/ ← one folder per session (gitignored) + ├── plan.md + ├── notes.md + ├── report.md + └── ... +``` + +--- + +## Session folder naming + +Format: `YYYYMMDD-HHmm-{slug}` + +- `YYYYMMDD-HHmm` — local time when the session started. +- `{slug}` — short, lowercase, kebab-case description of the task. 2-5 words. +- Examples: + - `20260617-1430-schema-refactor` + - `20260617-1500-fix-asset-dedup` + - `20260618-0900-doc-cleanup` + +If two sessions collide on the same minute, append `-2`, `-3`, etc. to the slug. + +--- + +## When to create a new session vs. reuse an existing one + +**Create a new session** when: + +- The user starts a new, unrelated task. +- A previous session is finished (`report.md` written) and a follow-up has a different goal. + +**Reuse the existing session** when: + +- The user says "continue", "keep going", "next step", or references the previous task. +- The work is the same task spread across multiple conversations. +- An open `todo.md` or `plan.md` still has unfinished items relevant to the new turn. + +When unsure, list the most recent session folders (`ls -1t .agents/sessions/`) and ask the user which to resume, or default to the most recent if it clearly matches. + +--- + +## Files inside a session + +All optional. Only create what the task actually needs. + +| File | Purpose | +| ------------ | --------------------------------------------------------------- | +| `plan.md` | Goal, constraints, ordered steps, decisions still to make | +| `notes.md` | Findings, snippets, code references (`file:line`), observations | +| `report.md` | Final summary when the task ends — what shipped, what didn't | +| `todo.md` | Outstanding actionable items, with owner if known | +| `context.md` | Background a future session must know to make sense of the work | +| `logs.md` | Verbatim command output / tool runs only when materially useful | + +--- + +## Writing rules + +1. Markdown only. No binaries, no screenshots unless the user provides them. +2. Bullet points and tables over prose. Aim for skimmable. +3. Reference source code with `path/to/file.ts:lineNumber`, never paste large source blocks. +4. Record decisions **and the reasoning** — future you will not remember why. +5. English only, matching the rest of the repo. +6. No secrets, tokens, passwords, or anything unsafe in a public gist. +7. Treat every session folder as ephemeral. If something must survive, promote it. + +--- + +## Quality bar + +A reviewer opening a session folder cold should, within ~2 minutes, answer: + +- What was the goal? +- What is the current state? +- What changed in the actual codebase? +- What, if anything, is still pending? diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 01bf51a9..71785aba 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,18 +20,31 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: pnpm - - uses: actions/configure-pages@v5 - - run: pnpm install - - run: pnpm docs:build && touch docs/.vitepress/dist/.nojekyll - - uses: actions/upload-pages-artifact@v4 + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build with VitePress + run: pnpm docs:build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 with: path: docs/.vitepress/dist @@ -41,5 +54,8 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} needs: build runs-on: ubuntu-latest + name: Deploy steps: - - uses: actions/deploy-pages@v4 + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0221e6b7..1b597bfb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,12 +10,23 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v6 with: - node-version: lts/* + node-version: 22.13.0 cache: pnpm - - run: pnpm install - - run: pnpm build - - run: pnpm test + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Test + run: pnpm test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fd55889b..e8350d39 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,28 +1,76 @@ name: Publish on: - push: - tags: - - v* + release: + types: [published] + workflow_dispatch: permissions: - id-token: write # Required for OIDC contents: read + id-token: write jobs: publish: - # prevents this action from running on forks - if: github.repository == 'zce/velite' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 with: - node-version: lts/* - registry-url: https://registry.npmjs.org/ - package-manager-cache: false # never use caching in release builds - - run: pnpm install - - run: pnpm build - - run: pnpm test - - run: pnpm publish --no-git-checks + node-version: 22.13.0 + registry-url: https://registry.npmjs.org + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Test + run: pnpm test + + - name: Build docs + run: pnpm docs:build + + - name: Resolve npm tag + run: | + if [ "${{ github.event.release.prerelease }}" = "true" ]; then + echo "NPM_TAG=next" >> "$GITHUB_ENV" + elif node -e "process.exit(require('./package.json').version.includes('-') ? 0 : 1)"; then + echo "NPM_TAG=next" >> "$GITHUB_ENV" + else + echo "NPM_TAG=latest" >> "$GITHUB_ENV" + fi + + - name: Publish core package + run: | + VERSION=$(node -p "require('./package.json').version") + if npm view "velite@$VERSION" version >/dev/null 2>&1; then + echo "velite@$VERSION is already published; skipping." + else + pnpm --filter velite publish --access public --provenance --no-git-checks --tag "$NPM_TAG" + fi + + - name: Publish Next.js plugin + run: | + VERSION=$(node -p "require('./packages/next/package.json').version") + if npm view "@velite/plugin-next@$VERSION" version >/dev/null 2>&1; then + echo "@velite/plugin-next@$VERSION is already published; skipping." + else + pnpm --filter @velite/plugin-next publish --access public --provenance --no-git-checks --tag "$NPM_TAG" + fi + + - name: Publish Vite plugin + run: | + VERSION=$(node -p "require('./packages/vite/package.json').version") + if npm view "@velite/plugin-vite@$VERSION" version >/dev/null 2>&1; then + echo "@velite/plugin-vite@$VERSION is already published; skipping." + else + pnpm --filter @velite/plugin-vite publish --access public --provenance --no-git-checks --tag "$NPM_TAG" + fi diff --git a/.vscode/settings.json b/.vscode/settings.json index 4b9d409e..8afcc94f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,6 @@ "files.exclude": { "**/node_modules": true }, - "typescript.tsdk": "node_modules/typescript/lib" + "js/ts.referencesCodeLens.enabled": true, + "js/ts.tsdk.path": "node_modules/typescript/lib" } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..20d0fd0c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,97 @@ +# AGENTS.md + +Velite — a tool that turns Markdown / MDX, YAML, JSON into a type-safe data layer using Zod schemas. + +## Quick reference + +| What | Command | +| --------------------------- | --------------- | +| Install deps | `pnpm install` | +| Build (type-check + bundle) | `pnpm build` | +| Run tests | `pnpm test` | +| Format code | `pnpm format` | +| Dev docs site | `pnpm docs:dev` | + +**Required order for verification:** `pnpm build` → `pnpm test` (tests run against built `dist/`). + +## Architecture + +- **Monorepo** managed by pnpm workspaces: root package, `docs/`, `examples/*`, `packages/*` +- Root package is the core library (`velite` on npm) +- `packages/next` → `@velite/plugin-next` (Next.js integration, hand-written JS) +- `packages/vite` → `@velite/plugin-vite` (Vite integration, hand-written JS) +- ESM-only (`"type": "module"`), Node.js >=22.13.0 + +## Source layout (`src/`) + +| File | Role | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `index.ts` | Public API entry and barrel, including public helpers/types plus the public `build()` facade | +| `cli.ts` | CLI entry (`velite build` / `velite dev`) | +| `app/` | Application orchestration: `Engine`, watch controller, and build options | +| `config/` | Public config types/helper plus runtime config loading/bundling | +| `collections/` | Public collection types/helper plus discovery, resolving, `VeliteFile`, and file cache | +| `output/` | Public output type plus generated entry/data/assets writing and emit cache | +| `assets/` | Asset store, asset path processing, image metadata, and Markdown/MDX linked-file plugins | +| `runtime/` | Schema parsing context, session store, build session, and logger | +| `loaders/` | Built-in loaders: `json`, `yaml`, `matter` (frontmatter) | +| `schemas/` | Custom Zod extensions: `file`, `image`, `markdown`, `mdx`, `slug`, `toc`, `excerpt`, `metadata`, `path`, `raw`, `isodate`, `unique` | +| `utils/` | Small shared utilities such as pattern matching | + +## Key patterns + +- `s` is the extended Zod namespace (`src/schemas/index.ts:16`) — re-exports all of `zod` plus custom schemas +- User config files (`velite.config.{js,ts,mjs,mts,cjs,cts}`) are bundled with esbuild at runtime, not imported directly (`src/config/load.ts`) +- Config is searched up to 3 parent directories from cwd (`src/config/load.ts`) +- Default content root: `content/`, default output: `.velite/` (data) + `public/static/` (assets) +- `defineConfig`, `defineCollection`, `defineLoader`, `defineSchema` are identity helpers for type inference only +- The `prepare` hook can return `false` to suppress default file output +- Tests use Node's built-in test runner (`node:test`), not Jest/Vitest +- All build-scoped mutable state lives on `BuildSession` (`src/runtime/session.ts`) and its `SessionStore`; independent builds are isolated by construction + +## Code style + +- Prettier: no semicolons, single quotes, no trailing commas, 160 char width +- Import sorting via `@ianvs/prettier-plugin-sort-imports` (configured in `prettier.config.js`) +- Pre-commit hook: `simple-git-hooks` → `lint-staged` → `prettier --write` + +## Testing + +```bash +pnpm test # runs: node --import tsx --test test/**/*.tests.ts +``` + +- Tests in `test/` use `node:test` + `node:assert` +- Tests run against the **built** output (`dist/`), so `pnpm build` must run first +- `test/basic.ts` builds the `examples/basic` fixture and checks generated output content +- Tests clean up `.velite` output dirs after running + +## Gotchas + +- The package bin points to `dist/cli.js`; `tsup` injects the Node shebang during build +- The `tsup` config injects a `require` shim banner for CJS interop in the ESM output +- Bundling strategy intentionally follows tsup defaults: `dependencies` stay external, while runtime internals listed only in `devDependencies` are bundled into `dist/` +- When adding runtime imports, put public API/native/heavy/override-sensitive deps in `dependencies`; put pure internal implementation tools in `devDependencies` so they are bundled +- After changing dependency groups, run `pnpm build` and check `dist/` for unexpected bare imports +- `sharp` and `esbuild` are allowed native builds in `pnpm-workspace.yaml` +- Config bundling uses `packages: 'external'` — user deps are not bundled into config output +- Internal modules are exposed through `src/index.ts` only when intentionally public; do not re-export implementation folders wholesale + +## Session workspace + +- **Plans, intermediate notes, per-task reports** go under `.agents/sessions/YYYYMMDD-HHmm-{slug}/`. +- **Save plans to:** `.agents/sessions/YYYYMMDD-HHmm-{slug}/plan.md` instead of the plugin or skill presets. +- **Save specs to:** `.agents/sessions/YYYYMMDD-HHmm-{slug}/specs.md` instead of the plugin or skill presets. +- Session subfolders are gitignored by default — never put long-lived conventions there; promote them to `.agents/knowledge/` instead. +- Full rules: `.agents/AGENTS.md` + +## Subdirectory AGENTS.md + +Load a subdirectory's `AGENTS.md` when you are about to work primarily in that directory: + +| When working in | Load | +| ----------------------------------------- | -------------------- | +| `packages/next` or `packages/vite` | `packages/AGENTS.md` | +| Planning, research, or session management | `.agents/AGENTS.md` | + +Skip if you are only passing through (e.g. a quick grep or single-file read). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/benchmark/incremental-rebuild.mjs b/benchmark/incremental-rebuild.mjs new file mode 100644 index 00000000..fca7fd0e --- /dev/null +++ b/benchmark/incremental-rebuild.mjs @@ -0,0 +1,204 @@ +// Incremental rebuild benchmark +// +// This benchmark is intentionally relative and local. It is NOT a CI assertion +// and produces no pass/fail status; numbers vary across machines, file systems, +// and Node versions. Use it to compare the cost of a full rebuild against the +// incremental paths (content rebuild, linked-asset owner rebuild) on a single +// machine while iterating on the Engine. +// +// Watch mode is currently the public long-lived caller of the Engine. The +// performance target is the Engine's incremental behavior itself; running +// scenarios through `watch()` exercises the same code paths that +// production users hit. +// +// Reading the output: +// - "full build" baseline cold build via build() +// - "watch start" time to first stable build through watch() +// - "content rebuild" time from mutating one document to .velite/c0.json +// reflecting the new value +// - "asset-owner rebuild" time from mutating shared.txt to c0.json reflecting +// the new fingerprinted public URL (only the owners +// of the asset should rebuild) +// +// Tuning: +// VELITE_BENCH_DOCS documents per collection (default 1000) +// VELITE_BENCH_COLLECTIONS number of collections (default 4) +// VELITE_BENCH_KEEP=1 keep the temp fixture on disk for inspection + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' + +const DOCS = Number(process.env.VELITE_BENCH_DOCS ?? 1000) +const COLLECTIONS = Number(process.env.VELITE_BENCH_COLLECTIONS ?? 4) +const KEEP = process.env.VELITE_BENCH_KEEP === '1' + +const waitFor = async (predicate, { interval = 20, timeout = 10_000, label = 'condition' } = {}) => { + const start = performance.now() + let timer + try { + while (true) { + try { + if (await predicate()) return + } catch { + // swallow transient errors (e.g. file not yet written) + } + if (performance.now() - start > timeout) { + throw new Error(`waitFor timed out after ${timeout}ms: ${label}`) + } + await new Promise(resolve => { + timer = setTimeout(resolve, interval) + }) + timer = undefined + } + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +const generateConfig = collections => { + const collectionEntries = Array.from({ length: collections }, (_, i) => { + const name = `c${i}` + const typeName = `C${i}` + return [ + ` ${name}: {`, + ` name: '${typeName}',`, + ` pattern: '${name}/*.json',`, + ` schema: s.object({ title: s.string(), file: s.file().optional() })`, + ` }` + ].join('\n') + }).join(',\n') + + return [ + "import { defineConfig, s } from 'velite'", + '', + 'export default defineConfig({', + " root: 'content',", + " output: { data: '.velite', assets: 'public/static', clean: true, name: '[name]-[hash:8].[ext]' },", + ' collections: {', + collectionEntries, + ' }', + '})', + '' + ].join('\n') +} + +const generateFixture = async root => { + const contentDir = join(root, 'content') + await mkdir(contentDir, { recursive: true }) + await writeFile(join(contentDir, 'shared.txt'), 'shared-v1') + + for (let c = 0; c < COLLECTIONS; c++) { + const dir = join(contentDir, `c${c}`) + await mkdir(dir, { recursive: true }) + for (let d = 0; d < DOCS; d++) { + const doc = { title: `c${c}-doc-${d}` } + if (d % 10 === 0) doc.file = '../shared.txt' + await writeFile(join(dir, `doc-${d}.json`), JSON.stringify(doc)) + } + } + + await writeFile(join(root, 'velite.config.mjs'), generateConfig(COLLECTIONS)) +} + +const main = async () => { + const { build, watch } = await import('../dist/index.js') + + const root = await mkdtemp(join(tmpdir(), 'velite-bench-')) + const configPath = join(root, 'velite.config.mjs') + const c0Json = join(root, '.velite', 'c0.json') + const firstDoc = join(root, 'content', 'c0', 'doc-0.json') + const sharedTxt = join(root, 'content', 'shared.txt') + + const results = [] + let watcher + + try { + await generateFixture(root) + + // 1) full build + { + const t0 = performance.now() + await build({ config: configPath, logLevel: 'silent' }) + const ms = performance.now() - t0 + results.push({ label: 'full build', ms: Number(ms.toFixed(2)) }) + } + + // 2) watch start (first build through the watcher) + { + const t0 = performance.now() + watcher = await watch({ config: configPath, logLevel: 'silent' }) + await waitFor( + async () => { + const text = await readFile(c0Json, 'utf8') + return text.includes('c0-doc-0') + }, + { label: 'watch start: c0.json initial content' } + ) + const ms = performance.now() - t0 + results.push({ label: 'watch start', ms: Number(ms.toFixed(2)) }) + } + + // Allow chokidar to finish wiring its watchers before we mutate files. + // `watch()` returns once the initial build resolves, but chokidar has + // `ignoreInitial: true` and needs a moment to attach its handlers; without + // this settle delay the first mutation can land before chokidar is ready. + await new Promise(resolve => setTimeout(resolve, 250)) + + // 3) content rebuild: mutate a single document, wait for c0.json to reflect it + { + const marker = `bench-content-${Date.now()}` + const t0 = performance.now() + await writeFile(firstDoc, JSON.stringify({ title: marker })) + await waitFor( + async () => { + const text = await readFile(c0Json, 'utf8') + return text.includes(marker) + }, + { label: 'content rebuild: c0.json reflects new title' } + ) + const ms = performance.now() - t0 + results.push({ label: 'content rebuild', ms: Number(ms.toFixed(2)) }) + } + + // 4) asset-owner rebuild: mutate shared.txt; only owner collections should rebuild. + // The fingerprinted public URL changes (new hash), so wait until the URL in + // c0.json differs from the snapshot taken before the mutation. + { + const before = await readFile(c0Json, 'utf8') + const t0 = performance.now() + await writeFile(sharedTxt, `shared-v2-${Date.now()}`) + await waitFor( + async () => { + const text = await readFile(c0Json, 'utf8') + return text.includes('shared') && text !== before + }, + { label: 'asset-owner rebuild: c0.json reflects new shared URL' } + ) + const ms = performance.now() - t0 + results.push({ label: 'asset-owner rebuild', ms: Number(ms.toFixed(2)) }) + } + + console.log(`fixture: ${COLLECTIONS} collections x ${DOCS} docs (${COLLECTIONS * DOCS} files) at ${root}`) + console.table(results) + } finally { + if (watcher) { + try { + await watcher.close() + } catch { + // ignore close errors during teardown + } + } + if (!KEEP) { + await rm(root, { recursive: true, force: true }) + } else { + console.log(`VELITE_BENCH_KEEP=1: fixture left at ${root}`) + } + } +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/bin/velite.js b/bin/velite.js deleted file mode 100755 index 5f5ecc9b..00000000 --- a/bin/velite.js +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -import('../dist/cli.js') -// for worksapce development diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1e8f9521..1f3ab537 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -52,6 +52,7 @@ export default defineConfig({ items: [ { text: 'Introduction', link: 'introduction' }, { text: 'Quick Start', link: 'quick-start' }, + { text: 'Migration to 1.0 Alpha', link: 'migration' }, { text: 'Define Collections', link: 'define-collections' }, { text: 'Using Collections', link: 'using-collections' }, { text: 'Velite Schemas', link: 'velite-schemas' } @@ -96,6 +97,7 @@ export default defineConfig({ base: '/guide/', items: [ { text: 'How Velite Works', link: 'how-it-works' }, + { text: 'Lifecycle', link: 'lifecycle' }, { text: 'Motivation', link: 'motivation' } ] }, diff --git a/docs/guide/custom-schema.md b/docs/guide/custom-schema.md index 4247eb1b..1d83b4cd 100644 --- a/docs/guide/custom-schema.md +++ b/docs/guide/custom-schema.md @@ -53,13 +53,13 @@ export const title = defineSchema(() => s.string().transform(value => value.toUp ```ts import { getImageMetadata, s } from 'velite' -import type { Image } from 'velite' +import type { VeliteImage } from 'velite' /** * Remote Image with metadata schema */ export const remoteImage = () => - s.string().transform(async (value, { addIssue }) => { + s.string().transform(async (value, ctx) => { try { const response = await fetch(value) const blob = await response.blob() @@ -69,7 +69,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 } }) @@ -78,44 +78,87 @@ export const remoteImage = () => ## Schema Context > [!TIP] -> Custom schemas often need to read the current file or resolved config. For new schemas, use `context()` to access this parser context. +> Custom schemas often need to read the current file or resolved config. Use `context()` to access the current build context. ```ts import { context, defineSchema, s } from 'velite' // convert a nonexistent field export const path = defineSchema(() => - s.custom().transform(value => { - if (value != null) return value - return context().file.path - }) + s + .string() + .optional() + .transform(value => { + if (value != null) return value + return context().file.path + }) ) ``` -`context()` must be called while Velite is parsing a schema, such as inside `.transform()`, `.refine()`, or `.superRefine()`. It returns the current parser context: +`context()` must be called while Velite is parsing a schema, such as inside `.transform()`, `.refine()`, or `.superRefine()`. It returns the current build context: ```ts -interface ParserContext { - readonly config: Config - readonly file: VeliteFile +interface BuildContext { + readonly config: ResolvedConfig + readonly file: ContentFile + readonly store: BuildStore } ``` -The previous schema callback `meta` value is still supported for compatibility: +`BuildStore` is an advanced API for sharing state within the current build or watch rebuild: ```ts -import { defineSchema, s } from 'velite' +const key = Symbol('my-schema.state') -export const path = defineSchema(() => - s.custom().transform((value, { meta }) => { - if (value != null) return value - return meta.path +export const counted = defineSchema(() => + s.string().transform(value => { + const state = context().store.getOrCreate(key, () => ({ count: 0 })) + state.count += 1 + return value }) ) ``` -Prefer `context()` for new custom schemas. It keeps file/config access explicit and avoids relying on Velite's extended Zod callback metadata. +When a custom schema derives a missing object field from `context()`, make the schema optional before the transform. Zod 4 only sends missing object keys into transforms when the field schema is optional: + +```ts +export const rawBody = defineSchema(() => + s + .custom(value => typeof value === 'string') + .optional() + .transform(value => value ?? context().file.content ?? '') +) +``` + +Built-in file-derived schemas such as `s.path()`, `s.raw()`, `s.markdown()`, `s.mdx()`, `s.excerpt()`, `s.metadata()`, and `s.toc()` include this optional wrapper. Value-required schemas such as `s.file()`, `s.image()`, `s.slug()`, `s.unique()`, and `s.isodate()` do not. + +### Error Handling in Transforms + +In Zod 4, schema callbacks receive a context object that provides `addIssue()` for reporting validation errors. + +```ts +import { defineSchema, s } from 'velite' + +export const safeTransform = defineSchema(() => + s.string().transform(async (value, ctx) => { + try { + const result = await processValue(value) + return result + } catch (err) { + ctx.addIssue({ + fatal: true, + code: 'custom', + message: err instanceof Error ? err.message : String(err) + }) + return null as never + } + }) +) +``` ### Reference -The type of `context().file` is [`VeliteFile`](../reference/types.md#velitefile). The type of the compatibility `meta` value is `ZodMeta`, which extends `VeliteFile`. +- `context()` returns `{ config: ResolvedConfig, file: ContentFile, store: BuildStore }`. +- `ctx.addIssue()` accepts `{ fatal?: boolean, code: string, message: string }`. +- See [`ContentFile`](../reference/types.md#contentfile) for file metadata structure. +- See [Lifecycle](./lifecycle.md) for build context and store lifetime details. diff --git a/docs/guide/define-collections.md b/docs/guide/define-collections.md index 4d095166..a3a895e5 100644 --- a/docs/guide/define-collections.md +++ b/docs/guide/define-collections.md @@ -67,7 +67,7 @@ const posts = defineCollection({ }) ``` -Velite uses [fast-glob](https://github.com/mrmlnc/fast-glob) to find content files, so you can use any glob pattern supported by fast-glob. +Velite uses [tinyglobby](https://github.com/SuperchupuDev/tinyglobby) to find content files, so you can use glob patterns compatible with tinyglobby. By default, Velite will ignore files and directories that start with `_` or `.`. @@ -86,14 +86,14 @@ const site = defineCollection({ Velite uses [Zod](https://zod.dev) to validate the content items in a collection. The `schema` option is used to define the Zod schema used to validate the content items in the collection. -To use Zod in Velite, import the `z` utility from `'velite'`. This is a re-export of Zod's `z` object, and it supports all of the features of Zod. See [Zod's Docs](https://zod.dev) for a complete documentation on how Zod works and what features are available. +To define schemas in Velite, import the `s` utility from `'velite'`. It includes all Zod schema helpers plus Velite-specific schemas for content projects. See [Zod's Docs](https://zod.dev) for complete documentation on how Zod works and what features are available. ```js -import { z } from 'velite' +import { s } from 'velite' const posts = defineCollection({ - schema: z.object({ - title: z.string().max(99) + schema: s.object({ + title: s.string().max(99) }) }) ``` @@ -104,7 +104,7 @@ The schema is usually a `ZodObject`, validating the shape of the content item. B ::: -For more complex schemas, I recommend that you use [Velite extended schemas `s`](velite-schemas.md): +For more complex schemas, use [Velite extended schemas `s`](velite-schemas.md): - `s.slug()`: validate slug format, unique in posts collection. - `s.isodate()`: format date string to ISO date string. @@ -159,36 +159,52 @@ const posts = defineCollection({ Use `context()` when a transform needs to read the current file or resolved config. This is useful for adding computed fields to the content items in a collection. ```js -import { context } from 'velite' +import { context, s } from 'velite' const posts = defineCollection({ schema: s .object({ // fields }) - .transform(data => ({ - ...data, - // computed fields - path: context().file.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 schema callback `meta` value is still supported for compatibility. For new custom schemas, prefer `context()`. 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 and plain text body on the current file context. +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 } from 'velite' +import { context, s } from 'velite' const posts = defineCollection({ schema: s.object({ - content: s.custom().transform(() => context().file.content), - plain: s.custom().transform(() => context().file.plain) + content: s + .string() + .optional() + .transform(() => { + const { file } = context() + return file.content + }), + plain: s + .string() + .optional() + .transform(() => { + const { file } = context() + return file.plain + }) }) }) ``` diff --git a/docs/guide/last-modified.md b/docs/guide/last-modified.md index 93ba43dc..e8a66181 100644 --- a/docs/guide/last-modified.md +++ b/docs/guide/last-modified.md @@ -16,13 +16,11 @@ import { context, defineSchema, s } from 'velite' const timestamp = defineSchema(() => s - .custom(i => i === undefined || typeof i === 'string') - .transform(async (value, { addIssue }) => { - if (value != null) { - addIssue({ fatal: false, code: 'custom', message: '`s.timestamp()` schema will resolve the file modified timestamp' }) - } - - const stats = await stat(context().file.path) + .string() + .optional() + .transform(async () => { + const { file } = context() + const stats = await stat(file.path) return stats.mtime.toISOString() }) ) @@ -51,12 +49,11 @@ const execAsync = promisify(exec) const timestamp = defineSchema(() => s - .custom(i => i === undefined || typeof i === 'string') - .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 ${context().file.path}`) + .string() + .optional() + .transform(async () => { + const { file } = context() + const { stdout } = await execAsync(`git log -1 --format=%cd ${file.path}`) return new Date(stdout || Date.now()).toISOString() }) ) diff --git a/docs/guide/lifecycle.md b/docs/guide/lifecycle.md new file mode 100644 index 00000000..63a48a16 --- /dev/null +++ b/docs/guide/lifecycle.md @@ -0,0 +1,56 @@ +# Lifecycle + +Velite exposes one public lifecycle concept for schema code: the build context. Internally, Velite uses build sessions and caches to execute builds, but those details are not public extension points. + +## Build Execution + +Each `build()` call runs one build execution. It loads the config, discovers matching content files, parses records with collection schemas, runs hooks, writes generated data, and copies collected assets. + +In watch mode, each rebuild is also a build execution. A rebuild may reuse internal output state so unchanged generated files can be skipped, but schema-visible state is scoped to the active build execution. + +## Build Context + +Use [`context()`](../reference/api.md#context) inside schema callbacks such as `.transform()`, `.refine()`, or `.superRefine()`. + +```ts +import { context, s } from 'velite' + +const sourcePath = s + .string() + .optional() + .transform(value => value ?? context().file.path) +``` + +`context()` returns the current `BuildContext`: + +```ts +interface BuildContext { + readonly config: ResolvedConfig + readonly file: ContentFile + readonly store: BuildStore +} +``` + +The context is ambient, similar to request-scoped APIs. It is available only while Velite is parsing a schema for the current file. + +## Build Store + +`context().store` is an advanced API for state shared within the current build execution. It avoids module-level globals and prevents state from leaking across independent builds. + +```ts +const key = Symbol('my-schema.state') + +const counted = s.string().transform(value => { + const state = context().store.getOrCreate(key, () => ({ count: 0 })) + state.count += 1 + return value +}) +``` + +Use `BuildStore` for custom schemas or plugins that need build-scoped registries, deduplication, or cross-field coordination. + +## Internal Sessions + +Velite internally creates a build session for each build or rebuild. The session owns mutable execution state such as file caches, resolved files, output state, diagnostics, logger injection, and the build store. + +`BuildSession` is intentionally internal. Public code should use `context()` and the public `BuildContext` shape instead of importing session internals. diff --git a/docs/guide/migration.md b/docs/guide/migration.md new file mode 100644 index 00000000..cce61630 --- /dev/null +++ b/docs/guide/migration.md @@ -0,0 +1,211 @@ +# Migration to 1.0 Alpha + +Velite 1.0 alpha modernizes the parser pipeline around Zod 4 and introduces framework plugins for app integrations. This guide covers the changes you should apply when upgrading from `0.3.x` or an earlier `1.0.0-alpha` build. + +## Upgrade Packages + +Install the alpha release and update your package manager lockfile: + +```bash +pnpm add -D velite@1.0.0-alpha.3 +``` + +If you use the framework plugins, install the matching package: + +```bash +pnpm add -D @velite/plugin-next@latest +pnpm add -D @velite/plugin-vite@latest +``` + +## Runtime Requirements + +Velite now targets Node.js `>=22.13.0`. + +This aligns the package with current versions of the runtime dependencies used by the build and watch pipeline. Update local development, CI, and deployment environments before upgrading. + +## Zod 4 Schema Semantics + +Velite now uses the official `zod` package internally. The `s` helper is the public schema namespace and includes Velite-specific schemas plus all Zod schema helpers. Use `s` instead of importing `z` from `velite`. + +```ts +import { s } from 'velite' + +const post = s.object({ + title: s.string(), + slug: s.path() +}) +``` + +### Replace `ctx.meta` With `context()` + +The parser no longer passes Velite file metadata through Zod's transform context. Use `context()` to access the current file, resolved config, and build-scoped store. + +Before: + +```ts +import { defineSchema, s } from 'velite' + +export const sourcePath = defineSchema(() => + s.custom().transform((value, { meta }) => { + return value ?? meta.path + }) +) +``` + +After: + +```ts +import { context, defineSchema, s } from 'velite' + +export const sourcePath = defineSchema(() => + s + .custom(value => typeof value === 'string') + .optional() + .transform(value => { + return value ?? context().file.path + }) +) +``` + +The `context()` function returns: + +```ts +{ + config: ResolvedConfig + file: ContentFile + store: BuildStore +} +``` + +### Mark Custom Context-Derived Fields as Optional + +In Zod 4, object keys are required unless the schema is explicitly optional. If a custom schema field is usually missing from frontmatter and should be derived from the current file, add `.optional()` before `.transform()`. + +Before: + +```ts +const posts = defineCollection({ + schema: s.object({ + content: s.custom().transform(value => value ?? context().file.content ?? '') + }) +}) +``` + +After: + +```ts +const posts = defineCollection({ + schema: s.object({ + content: s + .custom(value => typeof value === 'string') + .optional() + .transform(value => value ?? context().file.content ?? '') + }) +}) +``` + +Velite's built-in file-derived schemas already include this optional wrapper because missing fields are their normal derivation input. This includes `s.path()`, `s.raw()`, `s.markdown()`, `s.mdx()`, `s.excerpt()`, `s.metadata()`, and `s.toc()`. + +Value-required schemas do not include this wrapper. Use `.optional()` yourself when fields such as `s.file()`, `s.image()`, `s.slug()`, `s.unique()`, or `s.isodate()` should be optional. + +### Do Not Use `addIssue()` for Warnings + +Zod 4 treats any `ctx.addIssue()` call as a validation failure. `fatal: false` does not mean "warning" and does not keep the parse result successful. + +Before: + +```ts +s.string().transform((value, ctx) => { + ctx.addIssue({ fatal: false, code: 'custom', message: 'Using fallback value' }) + return value +}) +``` + +After: + +```ts +s.string().transform(value => { + return value +}) +``` + +Only call `ctx.addIssue()` when the current value should fail validation. + +## Asset Internals + +Velite 1.0 keeps low-level asset collection state internal. Custom schemas should use public schemas such as `s.file()` and `s.image()` for asset references, or return their own URLs directly. + +The internal asset store, linked-file remark/rehype plugins, and `processAsset()` are not public extension APIs. Do not import from `velite/dist/*` or source internals such as `src/assets/*`; those paths are implementation details and may change without a compatibility layer. + +## Next.js Integration + +Use `@velite/plugin-next` instead of manually starting Velite from `next.config.ts`. + +Before: + +```ts +const isDev = process.argv.includes('dev') +const isBuild = process.argv.includes('build') + +if (!process.env.VELITE_STARTED && (isDev || isBuild)) { + process.env.VELITE_STARTED = '1' + import('velite').then(m => m.build({ watch: isDev, clean: !isDev })) +} + +export default {} +``` + +After: + +```ts +import { withVelite } from '@velite/plugin-next' + +export default withVelite() +``` + +To pass Velite options: + +```ts +import { createNextPlugin } from '@velite/plugin-next' + +const withVelite = createNextPlugin({ config: './velite.config.ts' }) + +export default withVelite({ + reactStrictMode: true +}) +``` + +## Vite Integration + +`@velite/plugin-vite` supports Vite 5 through Vite 8. Keep the plugin in your Vite config and upgrade Vite normally: + +```ts +import velite from '@velite/plugin-vite' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [velite(), react()] +}) +``` + +## Type Exports + +The public entry exports Zod-related type helpers for schema typing: + +```ts +import type { infer, VeliteSchema } from 'velite' +``` + +Use `context()` for Velite parser metadata instead of relying on `ZodMeta`. + +## Recommended Upgrade Checklist + +- Update Node.js to `>=22.13.0` in local development and CI. +- Upgrade `velite` to `1.0.0-alpha.3`. +- Replace `import { z } from 'velite'` with `import { s } from 'velite'`. +- Replace `ctx.meta` access with `context()`. +- Add `.optional()` to custom schemas that derive missing object fields from the current file. +- Remove `ctx.addIssue({ fatal: false, ... })` warning patterns. +- Switch Next.js projects to `@velite/plugin-next`. +- Run `velite build`, your app build, and your type checks. diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index 607fd772..ba1a5e98 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -4,7 +4,7 @@ ### Prerequisites -- [Node.js](https://nodejs.org) version 18.20 or higher, LTS version is recommended. +- [Node.js](https://nodejs.org) version 22.13 or higher, LTS version is recommended. - macOS, Windows, and Linux are supported. ::: code-group diff --git a/docs/guide/using-mdx.md b/docs/guide/using-mdx.md index 8ce5958c..49b4038d 100644 --- a/docs/guide/using-mdx.md +++ b/docs/guide/using-mdx.md @@ -282,7 +282,7 @@ const compileMdx = async (source: string, path: string, options: CompileOptions) absWorkingDir: dirname(path), write: false, bundle: true, - target: 'node18', + target: 'node22', platform: 'neutral', format: 'esm', globalName: 'VELITE_MDX_COMPONENT', @@ -313,20 +313,17 @@ const compileMdx = async (source: string, path: string, options: CompileOptions) } export const mdxBundle = (options: MdxOptions = {}) => - s.custom().transform(async (value, { addIssue }) => { - const { config, file } = context() - const { path, content } = file - - value = value ?? content + s.custom().transform(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 } const enableGfm = options.gfm ?? config.mdx?.gfm ?? true const enableMinify = options.minify ?? config.mdx?.minify ?? true const removeComments = options.removeComments ?? config.mdx?.removeComments ?? true - const copyLinkedFiles = options.copyLinkedFiles ?? config.mdx?.copyLinkedFiles ?? true const outputFormat = options.outputFormat ?? config.mdx?.outputFormat ?? 'function-body' const remarkPlugins = [] as PluggableList @@ -334,7 +331,6 @@ export const mdxBundle = (options: MdxOptions = {}) => if (enableGfm) remarkPlugins.push(remarkGfm) // support gfm (autolink literals, footnotes, strikethrough, tables, tasklists). if (removeComments) remarkPlugins.push(remarkRemoveComments) // remove html comments - if (copyLinkedFiles) remarkPlugins.push([remarkCopyLinkedFiles, config.output]) // copy linked files to public path and replace their urls with public urls if (options.remarkPlugins != null) remarkPlugins.push(...options.remarkPlugins) // apply remark plugins if (options.rehypePlugins != null) rehypePlugins.push(...options.rehypePlugins) // apply rehype plugins if (config.mdx?.remarkPlugins != null) remarkPlugins.push(...config.mdx.remarkPlugins) // apply global remark plugins @@ -343,14 +339,16 @@ 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 } }) ``` +This example does not copy relative assets referenced from MDX. Use the built-in `s.mdx()` schema if you need Velite-managed linked-file copying, or add your own public remark/rehype plugin that returns URLs managed by your application. + Then, you can use the custom schema in your `velite.config.js`: ```js {10} diff --git a/docs/guide/velite-schemas.md b/docs/guide/velite-schemas.md index 5b2fe7e5..89b123b3 100644 --- a/docs/guide/velite-schemas.md +++ b/docs/guide/velite-schemas.md @@ -1,22 +1,13 @@ # Velite Schemas -To use Zod in Velite, import the `z` utility from `'velite'`. This is a re-export of the Zod library, and it supports all of the features of Zod. +To use Zod in Velite, import the `s` utility from `'velite'`. It includes all Zod schema helpers plus Velite-specific schemas for content projects. See [Zod's Docs](https://zod.dev) for complete documentation on how Zod works and what features are available. -```js -import { z } from 'velite' - -// `z` is re-export of Zod -``` - -In addition, Velite has extended Zod schemas, added some commonly used features when building content models, you can import `s` from `'velite'` to use these extended schemas. - ```js import { s } from 'velite' -// `s` is extended from Zod with some custom schemas, -// `s` also includes all members of zod, so you can use `s` as `z` +// `s` includes Zod helpers and Velite custom schemas. ``` ## `s.isodate()` @@ -25,6 +16,8 @@ import { s } from 'velite' Format date string to ISO date string. +This schema requires an input value. Add `.optional()` in your collection schema if the field itself is optional. + ```ts date: s.isodate() // case 1. valid date string @@ -43,13 +36,15 @@ date: s.isodate() Validate a string value that must be unique within a named group. +This schema requires an input value. Add `.optional()` in your collection schema if the field itself is optional. + ```ts name: s.unique('taxonomies') // case 1. unique value // 'foo' => 'foo' -// case 2. non-unique value in the 'taxonomies' group -// 'foo' => issue "duplicate value 'foo' ..." +// case 2. non-unique value (in all unique by 'taxonomies') +// 'foo' => issue 'Duplicate 'foo' with '/path/to/existing/file.yml'' ``` ### Parameters @@ -65,6 +60,8 @@ name: s.unique('taxonomies') Validate a slug string. It checks length, slug format, reserved values, and uniqueness within a named slug group. +This schema requires an input value. Add `.optional()` in your collection schema if the field itself is optional. + ```ts slug: s.slug('taxonomies', ['admin', 'login']) // case 1. unique slug value @@ -98,6 +95,8 @@ slug: s.slug('taxonomies', ['admin', 'login']) file path relative to this file, copy file to `config.output.assets` directory and return the public url. +This schema requires an input value. Add `.optional()` in your collection schema if the field itself is optional. + ```ts avatar: s.file() // case 1. relative path @@ -122,9 +121,11 @@ allow non-relative path, if true, the value will be returned directly, if false, ## `s.image(options)` -`string => Image` +`string => VeliteImage` -image path relative to this file, like `s.file()`, copy file to `config.output.assets` directory and return the [Image](#types) (image object with meta data). +Image path relative to the current file, like `s.file()`. Relative images are copied to `config.output.assets` and returned as [VeliteImage](#types) objects. + +This schema requires an input value. Add `.optional()` in your collection schema if the field itself is optional. ```ts avatar: s.image() @@ -141,7 +142,7 @@ avatar: s.image() // case 2. non-exists file // 'not-exists.png' => issue 'File not exists' -// case 3. absolute path or full url (if allowed) +// case 3. absolute path or full URL // '/icon.png' => { src: '/icon.png', width: 0, height: 0, blurDataURL: '', blurWidth: 0, blurHeight: 0 } // 'https://zce.me/logo.png' => { src: 'https://zce.me/logo.png', width: 0, height: 0, blurDataURL: '', blurWidth: 0, blurHeight: 0 } ``` @@ -176,7 +177,7 @@ avatar: s.image({ blur: { width: 16, quality: 30 } }) /** * Image object with metadata & blur image */ -interface Image { +interface VeliteImage { /** * public url of the image */ @@ -210,6 +211,8 @@ interface Image { parse input or document body as markdown content and return [Metadata](#types-1). +When the field is missing, this schema derives metadata from the current file's plain text. + currently only support `readingTime` & `wordCount`. ```ts @@ -241,6 +244,8 @@ interface Metadata { parse input or document body as markdown content and return excerpt text. +When the field is missing, this schema derives the excerpt from the current file's plain text. + ```ts excerpt: s.excerpt() // document body => excerpt text @@ -263,6 +268,8 @@ excerpt length. parse input or document body as markdown content and return html content. refer to [Markdown Support](using-markdown.md) for more information. +When the field is missing, this schema compiles the current file body. + ```ts content: s.markdown() // => html content @@ -281,6 +288,8 @@ content: s.markdown() parse input or document body as mdx content and return component function-body. refer to [MDX Support](using-mdx.md) for more information. +When the field is missing, this schema compiles the current file body. + ```ts code: s.mdx() // => function-body @@ -299,6 +308,8 @@ code: s.mdx() return raw document body. +When the field is missing, this schema returns the current file body. + ```ts code: s.raw() // => raw document body @@ -310,6 +321,8 @@ code: s.raw() parse input or document body as markdown content and return the [table of contents](#types-2). +When the field is missing, this schema derives the table of contents from the current file body. + ```ts toc: s.toc() // document body => table of contents @@ -380,6 +393,8 @@ Refer to [mdast-util-toc](https://github.com/syntax-tree/mdast-util-toc) for mor get flattened path based on the file path. +When the field is missing, this schema derives the value from the current file path. + ```ts path: s.path() // => flattened path, e.g. 'posts/2021-01-01-hello-world' @@ -403,7 +418,7 @@ Removes `index` from the path. In addition, all Zod's built-in schemas can be used normally, such as: ```ts -title: s.string().mix(3).max(100) +title: s.string().min(3).max(100) description: s.string().optional() featured: s.boolean().default(false) ``` diff --git a/docs/guide/with-nextjs.md b/docs/guide/with-nextjs.md index a09cedaa..2ce0be9f 100644 --- a/docs/guide/with-nextjs.md +++ b/docs/guide/with-nextjs.md @@ -152,14 +152,16 @@ In this case, you can specify a more specific type for the relevant schema to ma e.g. ```ts +import { defineCollection, s } from 'velite' + import type { Route } from 'next' -import type { Schema } from 'velite' +import type { VeliteSchema } from 'velite' const options = defineCollection({ // ... schema: s.object({ // ... - link: z.string() as Schema> + link: s.string() as VeliteSchema> }) }) ``` diff --git a/docs/other/snippets.md b/docs/other/snippets.md index fe3b2d5c..6785f328 100644 --- a/docs/other/snippets.md +++ b/docs/other/snippets.md @@ -10,13 +10,11 @@ import { context, defineSchema, s } from 'velite' const timestamp = defineSchema(() => s - .custom(i => i === undefined || typeof i === 'string') - .transform(async (value, { addIssue }) => { - if (value != null) { - addIssue({ fatal: false, code: 'custom', message: '`s.timestamp()` schema will resolve the file modified timestamp' }) - } - - const stats = await stat(context().file.path) + .string() + .optional() + .transform(async () => { + const { file } = context() + const stats = await stat(file.path) return stats.mtime.toISOString() }) ) @@ -42,12 +40,11 @@ const execAsync = promisify(exec) const timestamp = defineSchema(() => s - .custom(i => i === undefined || typeof i === 'string') - .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 ${context().file.path}`) + .string() + .optional() + .transform(async () => { + const { file } = context() + const { stdout } = await execAsync(`git log -1 --format=%cd ${file.path}`) return new Date(stdout || Date.now()).toISOString() }) ) @@ -67,13 +64,13 @@ const posts = defineCollection({ ```ts import { getImageMetadata, s } from 'velite' -import type { Image } from 'velite' +import type { VeliteImage } from 'velite' /** * Remote Image with metadata schema */ export const remoteImage = () => - s.string().transform(async (value, { addIssue }) => { + s.string().transform(async (value, ctx) => { try { const response = await fetch(value) const blob = await response.blob() @@ -83,7 +80,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 } }) @@ -144,7 +141,7 @@ const compileMdx = async (source: string): Promise => { absWorkingDir: resolve('content'), write: false, bundle: true, - target: 'node18', + target: 'node22', platform: 'neutral', format: 'esm', globalName: 'VELITE_MDX_COMPONENT', @@ -191,7 +188,7 @@ module.exports = { class VeliteWebpackPlugin { static started = false - constructor(/** @type {import('velite').Options} */ options = {}) { + constructor(/** @type {import('velite').BuildOptions} */ options = {}) { this.options = options } apply(/** @type {import('webpack').Compiler} */ compiler) { @@ -224,7 +221,7 @@ export default { class VeliteWebpackPlugin { static started = false - constructor(/** @type {import('velite').Options} */ options = {}) { + constructor(/** @type {import('velite').BuildOptions} */ options = {}) { this.options = options } apply(/** @type {import('webpack').Compiler} */ compiler) { @@ -255,8 +252,6 @@ import { fromMarkdown } from 'mdast-util-from-markdown' import { toHast } from 'mdast-util-to-hast' import { context, s } from 'velite' -import { extractHastLinkedFiles } from '../assets' - export interface ExcerptOptions { /** * Excerpt separator. @@ -273,22 +268,19 @@ export interface ExcerptOptions { } export const excerpt = ({ separator = 'more', length = 300 }: ExcerptOptions = {}) => - s.custom().transform(async (value, { addIssue }) => { - const { config, file } = context() - const { path, content } = file - - if (value == null && content != null) { - value = content + s.custom().transform(async (value, ctx) => { + const { file } = 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 }) return toHtml(output) } catch (err: any) { - addIssue({ fatal: true, code: 'custom', message: err.message }) + ctx.addIssue({ fatal: true, code: 'custom', message: err.message }) return value } }) diff --git a/docs/reference/api.md b/docs/reference/api.md index e5efb25d..29237b32 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -17,14 +17,14 @@ import { build } from 'velite' ### Signature ```ts -const build: (options?: Options) => Promise +const build: (options?: BuildOptions) => Promise> ``` ### Parameters #### `options` -- Type: `Options`, See [Options](#options). +- Type: `BuildOptions`, See [BuildOptions](#buildoptions). Options for build. @@ -48,6 +48,8 @@ Clean output directories before build. Watch files and rebuild on changes. +For programmatic watch mode, prefer [`watch()`](#watch) so you can close the returned watcher handle. `build({ watch: true })` preserves the historical build facade behavior and still returns only the initial build result. +