diff --git a/.changeset/incremental-sync-engine.md b/.changeset/incremental-sync-engine.md new file mode 100644 index 00000000..0b47b233 --- /dev/null +++ b/.changeset/incremental-sync-engine.md @@ -0,0 +1,15 @@ +--- +'@zpress/core': minor +'@zpress/cli': minor +--- + +Sync engine now only processes what changed instead of running a full sync on every pass. + +- **mtime-based page skip**: pages whose source mtime and frontmatter hash match the previous manifest skip the entire read/transform/hash pipeline +- **Parallel page copy**: all pages are copied concurrently via `Promise.all` instead of sequential reduce +- **Parallel `copyAll`**: public asset directory copy runs in parallel +- **Asset generation skip**: banner/logo/icon SVGs skip generation entirely when the asset config hash is unchanged; `shouldGenerate` also compares content to avoid redundant writes +- **Image copy skip**: destination images are skipped when their mtime is at least as recent as the source +- **OpenAPI spec caching**: specs are only re-parsed when their file mtime changes; a shared cache persists across dev-mode sync passes and is cleared on config reload +- **Structural change detection**: `resolvedCount` mismatch between syncs forces a full resync to handle added/removed pages correctly +- **Build system migration**: switched CLI from rslib to kidd's native build system (tsdown-based), with static command imports, proper dependency externalization, and React/Ink TUI dev screen diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f13200c..d69eba36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ Write clear, concise descriptions in the imperative mood ("add feature" not "add ## Project Structure -``` +```text packages/ ├── cli/ # @zpress/cli — CLI commands, watcher, Rspress integration ├── core/ # @zpress/core — config loading, sync engine, sidebar/nav generation diff --git a/contributing/README.md b/contributing/README.md index 9dbb7729..fa514972 100644 --- a/contributing/README.md +++ b/contributing/README.md @@ -6,6 +6,7 @@ Welcome to the zpress contributing docs. This directory contains standards, temp - **Standards** define the rules -- read the relevant standard before writing code or docs. - **Concepts** explain the "what" and "why" behind key architectural decisions. +- **References** are lookup tables for commands, flags, and APIs. - **Guides** are step-by-step walkthroughs for common tasks. ## Table of Contents @@ -38,10 +39,20 @@ Welcome to the zpress contributing docs. This directory contains standards, temp ### Concepts -- [Architecture](./concepts/architecture.md) -- Package ecosystem, sync engine, UI theme, data flow -- [CLI](./concepts/cli.md) -- Commands, dev server, file watching, build pipeline +- [Architecture](./concepts/architecture.md) -- Package ecosystem, layers, design decisions, data flow +- [Config](./concepts/config.md) -- Config system, output structure, Rspress integration +- [Engine](./concepts/engine/overview.md) -- Sync engine overview, build vs dev, key concepts + - [Pipeline](./concepts/engine/pipeline.md) -- Sync pipeline, page transformation, entry resolution + - [Incremental Sync](./concepts/engine/incremental.md) -- Mtime skipping, content hashing, structural change detection + - [OpenAPI Sync](./concepts/engine/openapi.md) -- Spec dereferencing, MDX generation, caching + - [Dev Mode](./concepts/engine/dev.md) -- File watching, debouncing, HMR, config reload + +### References + +- [CLI](./references/cli.md) -- Command syntax, flags, Rspress integration ### Guides - [Getting Started](./guides/getting-started.md) -- Local setup, reading order, Claude Code configuration - [Developing a Feature](./guides/developing-a-feature.md) -- Branch, code, test, changeset, PR, merge +- [Publishing VS Code Extension](./guides/publishing-vscode-extension.md) -- Package and publish the zpress VS Code extension diff --git a/contributing/concepts/architecture.md b/contributing/concepts/architecture.md index 70a232e6..73c53270 100644 --- a/contributing/concepts/architecture.md +++ b/contributing/concepts/architecture.md @@ -10,20 +10,26 @@ The codebase follows a functional, immutable, composition-first design. There ar ## Package Ecosystem -``` +```tree packages/ ├── core/ # Sync engine, config loading, sidebar/nav generation -├── cli/ # CLI commands (sync, dev, build, serve, clean, setup, dump, generate) +├── cli/ # CLI commands (dev, build, serve, check, diff, draft, clean, setup, dump) ├── ui/ # Rspress plugin, theme components, styles -└── zpress/ # @zpress/kit — public wrapper (re-exports core + ui + cli) +├── config/ # @zpress/config — c12-based config loading, Zod validation +├── templates/ # @zpress/templates — Liquid template registry for draft command +├── theme/ # @zpress/theme — theme definitions and schema +└── zpress/ # @zpress/kit — public wrapper (re-exports core + ui + cli) ``` -| Package | Purpose | -| -------------- | ------------------------------------------------------------------- | -| `@zpress/core` | Config loading, entry resolution, sync engine, sidebar/nav gen | -| `@zpress/cli` | CLI commands: sync, dev, build, serve, clean, setup, dump, generate | -| `@zpress/ui` | Rspress plugin, React theme components, CSS overrides | -| `@zpress/kit` | Public package: `.` and `./config` entry points + `zpress` CLI bin | +| Package | Purpose | +| ------------------- | ----------------------------------------------------------------------- | +| `@zpress/core` | Config loading, entry resolution, sync engine, sidebar/nav gen | +| `@zpress/cli` | CLI commands: dev, build, serve, check, diff, draft, clean, setup, dump | +| `@zpress/ui` | Rspress plugin, React theme components, CSS overrides | +| `@zpress/config` | Config schema (Zod), type definitions, c12-based loading | +| `@zpress/templates` | Liquid template registry for the `draft` command | +| `@zpress/theme` | Theme definitions and schema | +| `@zpress/kit` | Public package: `.` and `./config` entry points + `zpress` CLI bin | ### `@zpress/kit` (wrapper) @@ -57,14 +63,15 @@ The `zpress` CLI bin is provided by this package and delegates to `@zpress/cli`. }}%% flowchart TB subgraph cli ["CLI Layer"] - SYNC(["sync"]) DEV(["dev"]) BUILD(["build"]) SERVE(["serve"]) + CHECK(["check"]) + DIFF(["diff"]) + DRAFT(["draft"]) CLEAN(["clean"]) SETUP(["setup"]) DUMP(["dump"]) - GENERATE(["generate"]) end subgraph core ["Core Layer"] @@ -85,68 +92,71 @@ flowchart TB subgraph output [".zpress/"] CONTENT(["content/"]) - GENERATED(["generated/"]) + GENERATED([".generated/"]) DIST(["dist/"]) end - DEV & BUILD --> SYNC - SYNC --> CONFIG --> RESOLVE --> COPY --> CONTENT + DEV & BUILD --> CONFIG + CONFIG --> RESOLVE --> COPY --> CONTENT RESOLVE --> SIDEBAR & NAV & HOME --> GENERATED RSPRESS_CFG --> GENERATED PLUGIN & THEME --> DIST classDef core fill:#313244,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4 - classDef gateway fill:#313244,stroke:#fab387,stroke-width:2px,color:#cdd6f4 classDef agent fill:#313244,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4 classDef external fill:#313244,stroke:#f5c2e7,stroke-width:2px,color:#cdd6f4 - class SYNC,DEV,BUILD,SERVE,CLEAN,SETUP,DUMP,GENERATE external + class DEV,BUILD,SERVE,CHECK,DIFF,DRAFT,CLEAN,SETUP,DUMP external class CONFIG,RESOLVE,COPY,SIDEBAR,NAV,HOME,MANIFEST core class PLUGIN,THEME,RSPRESS_CFG agent - class CONTENT,GENERATED,DIST gateway + class CONTENT,GENERATED,DIST agent style cli fill:#181825,stroke:#f5c2e7,stroke-width:2px style core fill:#181825,stroke:#89b4fa,stroke-width:2px style ui fill:#181825,stroke:#a6e3a1,stroke-width:2px - style output fill:#181825,stroke:#fab387,stroke-width:2px + style output fill:#181825,stroke:#a6e3a1,stroke-width:2px ``` ### CLI Layer **Package:** `@zpress/cli` -The command-line interface. Uses [`@kidd-cli/core`](https://github.com/kidd-framework/kidd-cli) for command routing and `@kidd-cli/core/logger` for styled terminal output. Commands orchestrate the core sync engine and Rspress build APIs. +The command-line interface. Uses [`@kidd-cli/core`](https://github.com/kidd-framework/kidd-cli) for command routing and `@kidd-cli/core/logger` for styled terminal output. Commands orchestrate the core sync engine and Rspress build APIs. See [CLI Reference](../references/cli.md) for command details. ### Core Layer **Package:** `@zpress/core` -The sync engine and config system. This is where the information architecture is resolved: - -| Module | Purpose | -| --------------------------- | --------------------------------------------------------- | -| `config.ts` | Config file discovery and loading via c12 | -| `define-config.ts` | Config validation at the boundary | -| `paths.ts` | Path constants for `.zpress/` output structure | -| `sync/index.ts` | Main sync pipeline orchestrator | -| `sync/errors.ts` | SyncError and ConfigError definitions | -| `sync/types.ts` | Sync-specific type definitions | -| `sync/copy.ts` | Page writing with frontmatter injection and hash tracking | -| `sync/home.ts` | Default home page generation | -| `sync/manifest.ts` | Incremental sync tracking via content hashes | -| `sync/planning.ts` | Planning page discovery from `.planning/` directory | -| `sync/rewrite-links.ts` | Relative link rewriting during copy | -| `sync/strip-xml.ts` | XML tag stripping for planning documents | -| `sync/workspace.ts` | Workspace item synthesis and card enrichment | -| `sync/resolve/index.ts` | Entry tree resolution (globs, text derivation, sorting) | -| `sync/resolve/path.ts` | Path resolution utilities | -| `sync/resolve/recursive.ts` | Recursive directory resolution | -| `sync/resolve/sort.ts` | Entry sorting strategies | -| `sync/resolve/text.ts` | Text derivation from filename/heading/frontmatter | -| `sync/sidebar/index.ts` | Sidebar and nav JSON generation | -| `sync/sidebar/multi.ts` | Multi-sidebar namespace building | -| `sync/sidebar/inject.ts` | Virtual landing page generation (MDX) | -| `sync/sidebar/landing.ts` | Landing page MDX generation | +The sync engine and config system. See [Engine](./engine/overview.md) for pipeline details. + +| Module | Purpose | +| ---------------------------- | --------------------------------------------------------- | +| `config.ts` | Config file discovery and loading via c12 | +| `define-config.ts` | Config validation at the boundary | +| `paths.ts` | Path constants for `.zpress/` output structure | +| `sync/index.ts` | Main sync pipeline orchestrator | +| `sync/errors.ts` | SyncError and ConfigError definitions | +| `sync/types.ts` | Sync-specific type definitions | +| `sync/copy.ts` | Page writing with frontmatter injection and hash tracking | +| `sync/home.ts` | Default home page generation | +| `sync/manifest.ts` | Incremental sync tracking via content hashes | +| `sync/openapi.ts` | OpenAPI spec sync (dereference, MDX generation) | +| `sync/images.ts` | Image discovery, copy, and path rewriting | +| `sync/planning.ts` | Planning page discovery from `.planning/` directory | +| `sync/rewrite-links.ts` | Relative link rewriting during copy | +| `sync/strip-xml.ts` | XML tag stripping for planning documents | +| `sync/workspace.ts` | Workspace item synthesis and card enrichment | +| `sync/collect-workspaces.ts` | Workspace item collection from config | +| `sync/resolve/index.ts` | Entry tree resolution (globs, text derivation, sorting) | +| `sync/resolve/path.ts` | Path resolution utilities | +| `sync/resolve/recursive.ts` | Recursive directory resolution | +| `sync/resolve/sort.ts` | Entry sorting strategies | +| `sync/resolve/text.ts` | Text derivation from filename/heading/frontmatter | +| `sync/sidebar/index.ts` | Sidebar and nav JSON generation | +| `sync/sidebar/multi.ts` | Multi-sidebar namespace building | +| `sync/sidebar/inject.ts` | Virtual landing page generation (MDX) | +| `sync/sidebar/landing.ts` | Landing page MDX generation | +| `banner/index.ts` | SVG asset generation (banner, logo, icon) | ### UI Layer @@ -163,116 +173,6 @@ The Rspress theme and plugin: | `theme/icons` | Iconify icon mappings for tech tags | | `theme/styles` | CSS overrides for Rspress default theme | -## Sync Engine - -The sync engine is the heart of zpress. It transforms a config file into a complete documentation site structure. - -### Pipeline - -The `sync()` function in `packages/core/src/sync/index.ts` runs this pipeline: - -1. **Setup** -- Create output directories, seed default assets -2. **Asset generation** -- Generate branded SVG assets (banner, logo, icon) from config title -3. **Public asset copying** -- Copy `.zpress/public/` into content dir for Rspress resolution -4. **Load manifest** -- Load previous sync manifest for incremental diffing -5. **Workspace synthesis** -- Convert `apps`/`packages`/`workspaces` into entry sections -6. **Resolve entries** -- Walk the config tree, resolve globs, derive text, merge frontmatter -7. **Enrich cards** -- Attach workspace metadata (icon, scope, tags, badge) to matched entries -8. **Inject landing pages** -- Generate virtual MDX pages for sections with children but no page -9. **Collect pages** -- Flatten the resolved tree into a flat page list -10. **Generate home** -- Create default home page from config metadata (when no explicit index.md) -11. **Planning page discovery** -- Discover and resolve pages from `.planning/` directory -12. **Copy pages** -- Build source map, write all pages with injected frontmatter, rewrite links, track SHA256 hashes -13. **Clean stale files** -- Remove files present in old manifest but absent in new -14. **Generate sidebar + nav** -- Build multi-sidebar JSON and nav array -15. **Save manifest** -- Record file hashes for incremental sync on next run -16. **Write README** -- Generate bare-bones README in `.zpress/` root - -Returns: `{ pagesWritten, pagesSkipped, pagesRemoved, elapsed }` - -### Entry Resolution - -The entry resolver (`sync/resolve/index.ts`) recursively walks the config tree and resolves each entry: - -- **Single file** -- Source file with explicit link (e.g., `from: 'docs/getting-started.md'`) -- **Virtual page** -- Generated content with link (e.g., `content: () => '# Hello'`) -- **Glob section** -- Pattern that discovers files (e.g., `from: 'docs/guides/*.md'`) -- **Recursive glob** -- Directory-driven nesting (e.g., `from: 'docs/**/*.md', recursive: true`) -- **Explicit items** -- Hand-written child entries - -Text derivation is configurable via `textFrom`: - -| Value | Source | -| --------------- | ------------------------------------------ | -| `'filename'` | Kebab-case filename → title case (default) | -| `'heading'` | First `#` heading in the markdown file | -| `'frontmatter'` | `title` field from YAML frontmatter | - -### Incremental Sync - -Every page is tracked in a manifest (`manifest.json`) with its SHA256 content hash. On subsequent syncs, pages with unchanged hashes are skipped. Stale files (present in old manifest but not new) are removed. This enables fast re-syncs during development. - -### Multi-Sidebar - -zpress generates a multi-sidebar structure for Rspress. Root entries share the `/` namespace. Isolated sections (workspace items, explicit `isolated: true`) get their own namespace (e.g., `/apps/api/`). This allows each section to have an independent sidebar tree. - -## Config System - -The config file (`zpress.config.ts`) is the single source of truth for the entire documentation site: - -```ts -import { defineConfig } from '@zpress/kit' - -export default defineConfig({ - title: 'My Docs', - description: 'Platform documentation', - tagline: 'A short tagline for the hero section', - sections: [ - /* entry tree */ - ], - apps: [ - /* workspace items */ - ], - packages: [ - /* workspace items */ - ], - nav: 'auto', -}) -``` - -| Field | Purpose | -| ------------- | ------------------------------------------------- | -| `title` | Site title, used in hero and metadata | -| `description` | Site description for SEO | -| `tagline` | Hero section subtitle | -| `sections` | Entry tree defining the information architecture | -| `apps` | Workspace items for application docs | -| `packages` | Workspace items for shared package docs | -| `workspaces` | Custom workspace groups | -| `nav` | Top-level navigation (`'auto'` or explicit array) | - -Config is loaded via [c12](https://github.com/unjs/c12) and validated at the boundary in `defineConfig()`. Validation errors exit immediately with a descriptive message. - -## Output Structure - -The sync engine writes everything to `.zpress/`: - -``` -.zpress/ -├── content/ # Synced markdown + generated MDX -│ ├── index.md # Home page (auto-generated or from source) -│ ├── getting-started.md -│ ├── guides/ -│ └── .generated/ # Machine-generated metadata -│ ├── sidebar.json # Multi-sidebar config -│ ├── nav.json # Top-level navigation -│ ├── workspaces.json # Workspace data for home page -│ └── manifest.json # Sync state (file hashes) -├── public/ # Static assets (logos, icons) -├── dist/ # Build output (HTML, CSS, JS) -└── cache/ # Rspress build cache -``` - ## Data Flow ```mermaid @@ -323,16 +223,6 @@ sequenceDiagram end ``` -### Step-by-step - -1. **Load config** -- c12 discovers and parses `zpress.config.ts` -2. **Validate** -- `defineConfig()` validates structure, exits on first error -3. **Sync entries** -- Resolve globs, derive text, merge frontmatter, deduplicate -4. **Generate metadata** -- Sidebar JSON, nav JSON, workspace data, home page -5. **Write content** -- Copy/generate all pages to `.zpress/content/` with frontmatter injection -6. **Build** -- Rspress reads the generated content and metadata, renders with zpress theme -7. **Output** -- Static HTML/CSS/JS written to `.zpress/dist/` - ## Error Handling zpress uses the `Result` tuple pattern for expected failures: @@ -343,21 +233,12 @@ zpress uses the `Result` tuple pattern for expected failures: | Config | Validate-and-exit at boundary | `process.exit(1)` with message | | CLI | Catch and report | `@kidd-cli/core/logger` error display | -**Result tuples** are used for operations that can fail (config parsing, file I/O, glob resolution): - -```ts -const [error, config] = loadConfig(workspace) -if (error) return [error, null] -``` - -**Config validation** exits immediately in `defineConfig()` -- config errors are always fatal because nothing can proceed without a valid config. - ## Design Decisions 1. **Config is the information architecture** -- A single file defines content structure, routing, navigation, and metadata. No separate sidebar/nav config files. 2. **Factories over classes** -- All components are factory functions returning plain objects. 3. **Result tuples over throw** -- Expected failures use `Result`. No exceptions. -4. **Incremental sync** -- SHA256 hashes skip unchanged pages. Manifest comparison removes stale files. +4. **Incremental sync** -- Mtime checks, content hashes, and config hashes skip unchanged work. Manifest comparison removes stale files. 5. **Virtual pages via MDX** -- Landing pages are generated at sync time as MDX with React components. 6. **Multi-sidebar from config** -- Isolated sections get independent sidebar namespaces automatically. 7. **Glob-driven content discovery** -- Patterns auto-discover files without manual entry per page. @@ -365,7 +246,9 @@ if (error) return [error, null] ## References -- [CLI](./cli.md) +- [Engine](./engine/overview.md) +- [Config](./config.md) +- [CLI Reference](../references/cli.md) - [Coding Style](../standards/typescript/coding-style.md) - [Design Patterns](../standards/typescript/design-patterns.md) - [Errors](../standards/typescript/errors.md) diff --git a/contributing/concepts/cli.md b/contributing/concepts/cli.md deleted file mode 100644 index 06a55387..00000000 --- a/contributing/concepts/cli.md +++ /dev/null @@ -1,180 +0,0 @@ -# CLI - -Overview of the zpress CLI -- commands, the dev server, file watching, and the build pipeline. - -## Overview - -zpress uses [`@kidd-cli/core`](https://github.com/kidd-framework/kidd-cli) for command routing and `@kidd-cli/core/logger` for styled terminal output. The CLI entry point is in `packages/cli/src/`, which registers all commands. Each command is a standalone module that orchestrates the core sync engine and Rspress build APIs. - -## Commands - -zpress has eight commands: - -| Command | Description | -| ---------- | ------------------------------------------------ | -| `sync` | Run the sync engine (config → `.zpress/`) | -| `dev` | Sync + Rspress dev server + file watcher | -| `build` | Sync + Rspress static build | -| `serve` | Preview a built site from `.zpress/dist/` | -| `clean` | Remove `.zpress/cache/`, `content/`, and `dist/` | -| `setup` | Create a starter `zpress.config.ts` | -| `dump` | Resolve the full entry tree and print as JSON | -| `generate` | Generate banner and logo SVGs from project title | - -### `sync` - -```bash -zpress sync [--quiet] -``` - -Runs the sync engine once. Loads the config, resolves all entries, writes content to `.zpress/content/`, generates sidebar/nav JSON, and reports write/skip/remove counts. - -This is the core operation that all other commands build on. - -### `dev` - -```bash -zpress dev [--quiet] [--clean] -``` - -The primary development workflow. Combines three operations: - -1. **Initial sync** -- Full sync to populate `.zpress/content/` -2. **File watcher** -- Monitors source directories for changes -3. **Rspress dev server** -- Starts on `http://localhost:6174` with hot module replacement - -The `--clean` flag removes cache, content, and dist before starting. - -#### File Watching - -The watcher (`packages/cli/src/lib/watcher.ts`) monitors: - -- **Glob entries** -- Parent directories of glob patterns (chokidar handles recursion) -- **Single-file entries** -- Individual source files -- **Config file** -- `zpress.config.ts` (triggers config reload on next sync) -- **Planning directory** -- Optional `.planning/` directory - -Watch behavior: - -- Markdown file changes (add, change, remove) trigger a debounced re-sync (150ms) -- Config file changes reload the config object for the next sync cycle -- Duplicate watch paths are deduplicated (child paths under a watched parent are skipped) -- Concurrent syncs are prevented -- if a sync is running, the next change queues a pending resync - -### `build` - -```bash -zpress build [--quiet] [--clean] -``` - -Produces a static site: - -1. Optional clean step -2. Full sync -3. Rspress build (generates optimized HTML/CSS/JS in `.zpress/dist/`) - -### `serve` - -```bash -zpress serve [--no-open] -``` - -Starts a static file server pointing at `.zpress/dist/` on `http://localhost:6174`. The browser opens automatically; use `--no-open` to disable. - -### `clean` - -```bash -zpress clean -``` - -Removes `.zpress/cache/`, `content/`, and `dist/`. Safe to run at any time -- all content is regenerated by sync/build. - -### `setup` - -```bash -zpress setup -``` - -Creates a starter `zpress.config.ts` in the current directory if one does not already exist. - -### `dump` - -```bash -zpress dump -``` - -Resolves the full entry tree from the config and prints it as JSON. Useful for debugging config resolution and glob patterns. - -### `generate` - -```bash -zpress generate -``` - -Generates branded banner and logo SVG assets from the project title. Reads `title` and optional `tagline` from `zpress.config.ts`, generates SVGs, and writes them to `.zpress/public/`. Skips generation when no title is configured. Does not overwrite files that have been manually customized. - -## Rspress Integration - -The CLI communicates with Rspress through `packages/cli/src/rspress.ts`: - -| Function | Purpose | -| ------------------ | -------------------------------------------- | -| `startDevServer()` | Launch Rspress dev server on port 6174 | -| `buildSite()` | Run Rspress static build to `.zpress/dist/` | -| `serveSite()` | Start static file server for `.zpress/dist/` | -| `openBrowser()` | Cross-platform browser launcher | - -All functions receive a Rspress config object built by `createRspressConfig()` from `@zpress/ui`. This config loads the generated JSON files (sidebar, nav, workspaces) from `.zpress/content/.generated/` and wires up the zpress theme. - -## Command Lifecycle - -### `zpress dev` (detailed) - -``` -1. Parse args (@kidd-cli/core) -2. Create paths (.zpress/) -3. Load config (c12) -4. Clean (optional: remove cache/content/dist) -5. Run initial sync - └── resolve entries → write content → generate sidebar/nav → save manifest -6. Create file watcher (chokidar) - ├── Watch source directories from config - ├── Watch config file for reloads - └── Watch .planning/ (optional) -7. Start Rspress dev server (:6174) -8. On file change: - ├── Debounce 150ms - ├── If config changed → reload config - ├── Re-sync (incremental, skips unchanged pages) - └── Rspress HMR picks up content changes -``` - -### `zpress build` (detailed) - -``` -1. Parse args (@kidd-cli/core) -2. Create paths (.zpress/) -3. Load config (c12) -4. Clean (optional) -5. Run full sync -6. Run Rspress build - ├── Read content/ + .generated/ JSONs - ├── Render with zpress theme - └── Write optimized static site to dist/ -``` - -## Error Handling - -CLI errors are handled at the command boundary: - -- **Config errors** -- `defineConfig()` exits with a descriptive message via `process.exit(1)` -- **Sync errors** -- Result tuples propagate up; the CLI reports them via `@kidd-cli/core/logger` -- **Rspress errors** -- Build/dev failures are caught and reported - -No command calls `process.exit` directly except through the config validation boundary. All user-facing error formatting is centralized in the CLI layer. - -## References - -- [Architecture](./architecture.md) -- [Coding Style](../standards/typescript/coding-style.md) -- [Errors](../standards/typescript/errors.md) diff --git a/contributing/concepts/config.md b/contributing/concepts/config.md new file mode 100644 index 00000000..2c2d0bfa --- /dev/null +++ b/contributing/concepts/config.md @@ -0,0 +1,84 @@ +# Config + +The single source of truth for the entire documentation site. + +## Overview + +The config file (`zpress.config.ts`) defines the information architecture -- content structure, navigation, metadata, and workspaces. It is loaded via [c12](https://github.com/unjs/c12) and validated at runtime by `loadConfig()`. Validation errors are returned as `Result` tuples; the CLI layer handles reporting and exits. + +## Supported Formats + +| Format | Files | +| ---------- | ----------------------------------------------- | +| TypeScript | `zpress.config.ts`, `.mts`, `.cts` | +| JavaScript | `zpress.config.js`, `.mjs`, `.cjs` | +| Data | `zpress.config.json`, `.jsonc`, `.yml`, `.yaml` | + +## Shape + +```ts +import { defineConfig } from '@zpress/kit' + +export default defineConfig({ + title: 'My Docs', + description: 'Platform documentation', + tagline: 'A short tagline for the hero section', + sections: [ + /* entry tree */ + ], + apps: [ + /* workspace items */ + ], + packages: [ + /* workspace items */ + ], + nav: 'auto', +}) +``` + +| Field | Purpose | +| ------------- | ------------------------------------------------- | +| `title` | Site title, used in hero and metadata | +| `description` | Site description for SEO | +| `tagline` | Hero section subtitle | +| `sections` | Entry tree defining the information architecture | +| `apps` | Workspace items for application docs | +| `packages` | Workspace items for shared package docs | +| `workspaces` | Custom workspace groups | +| `nav` | Top-level navigation (`'auto'` or explicit array) | + +## Output Structure + +The sync engine writes everything to `.zpress/`: + +```tree +.zpress/ +├── content/ # Synced markdown + generated MDX (Rspress root) +│ ├── index.md # Home page (auto-generated or from source) +│ ├── getting-started.md +│ ├── guides/ +│ └── .generated/ # Machine-generated metadata +│ ├── sidebar.json # Multi-sidebar config +│ ├── nav.json # Top-level navigation +│ └── workspaces.json # Workspace data for home page +├── public/ # Static assets (logos, icons, banners) +├── dist/ # Build output (HTML, CSS, JS) +└── cache/ # Rspress build cache +``` + +Rspress's root is set to `.zpress/content/`. It never sees the original repo layout. + +## Rspress Integration + +`createRspressConfig()` in `@zpress/ui` bridges sync output to Rspress: + +- Sets Rspress `root` to `.zpress/content/` +- Reads generated `sidebar.json`, `nav.json`, `workspaces.json` from `.generated/` +- Registers the zpress plugin (theme provider, edit-source button, mermaid, file trees) +- Configures Rsbuild aliases so generated MDX can import `@zpress/ui/theme` components + +## References + +- [Architecture](./architecture.md) +- [Engine](./engine/overview.md) +- [CLI Reference](../references/cli.md) diff --git a/contributing/concepts/engine/dev.md b/contributing/concepts/engine/dev.md new file mode 100644 index 00000000..18e190db --- /dev/null +++ b/contributing/concepts/engine/dev.md @@ -0,0 +1,100 @@ +# Dev Mode + +The watch loop that powers `zpress dev` -- file watching, incremental resyncs, and Rspress hot reload. + +## Overview + +Dev mode combines three systems: an initial full sync, a file watcher that triggers incremental resyncs, and a Rspress dev server that picks up content changes via HMR. The watcher is the orchestrator -- it decides what kind of sync to run and whether to restart Rspress. + +```mermaid +%%{init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#313244', + 'primaryTextColor': '#cdd6f4', + 'primaryBorderColor': '#6c7086', + 'lineColor': '#89b4fa', + 'secondaryColor': '#45475a', + 'tertiaryColor': '#1e1e2e', + 'actorBkg': '#313244', + 'actorBorder': '#89b4fa', + 'actorTextColor': '#cdd6f4', + 'signalColor': '#cdd6f4', + 'signalTextColor': '#cdd6f4' + } +}}%% +sequenceDiagram + participant W as Watcher + participant S as Sync Engine + participant R as Rspress + participant B as Browser + + rect rgb(49, 50, 68) + Note over W,R: Markdown change + W->>W: Debounce 150ms + W->>S: Incremental sync + S-->>W: Pages written/skipped + Note over S,R: Rspress detects file changes + R->>B: HMR update + end + + rect rgb(49, 50, 68) + Note over W,R: Config change + W->>W: Debounce 150ms + W->>W: Reload config + clear caches + W->>S: Full sync + S-->>W: Pages written + W->>R: Restart dev server + R->>B: Full reload + end +``` + +## Lifecycle + +```text +1. Parse args (@kidd-cli/core) +2. Resolve paths (.zpress/) +3. Load config (c12) +4. Clean (optional: remove cache/content/dist) +5. Create shared OpenAPI cache +6. Run initial sync (full) +7. Start Rspress dev server (:6174) +8. Create file watcher (fs.watch, recursive: true) +9. Enter watch loop +``` + +## File Watching + +The watcher (`packages/cli/src/lib/watcher.ts`) uses Node.js native `fs.watch` with `recursive: true` -- a single FSEvents subscription on macOS, a single inotify recursive watch on Linux (Node 22+). It monitors the entire repo root and filters events in the callback. + +**Ignored directories:** `node_modules`, `.git`, `.zpress`, `dist`, `.turbo`, `bundle` + +### Trigger Table + +| Event | Trigger | What happens | +| ----------------------------------------- | -------------- | ----------------------------------------------------------------------------- | +| `.md`/`.mdx` change | 150ms debounce | Incremental `sync()` -- unchanged pages skipped via mtime + content hash | +| `zpress.config.*` change (repo root only) | 150ms debounce | Reload config, full `sync()`, restart Rspress dev server (clears build cache) | +| OpenAPI spec change (`.yaml`/`.json`) | -- | Not watched -- restart `dev` or trigger a config change to re-parse | +| Non-markdown file change | -- | Ignored | +| Files in ignored dirs | -- | Dropped silently | + +## Concurrency + +If a sync is already running, the next change queues a pending resync. Config reload state is tracked across queued syncs so a content change followed by a config change still triggers a full reload. After 5 consecutive sync failures, pending resyncs are dropped until the next file change. + +## Rspress Restart + +When a config change triggers a reload, the watcher invokes `onConfigReload` after sync completes. This restarts the Rspress dev server with a fresh config (disabling persistent build cache so title/theme/color changes take effect). Content-only changes do not restart Rspress -- its HMR picks up the updated files directly from `.zpress/content/`. + +## OpenAPI Cache + +A shared `Map` is created once in the `dev` command and threaded through all sync passes. Dereferenced OpenAPI specs persist in the cache across resyncs, avoiding expensive re-parsing on content-only changes. The cache is cleared on config reload to force re-parsing. + +## References + +- [Engine Overview](./overview.md) +- [Pipeline](./pipeline.md) +- [Incremental Sync](./incremental.md) +- [OpenAPI Sync](./openapi.md) +- [CLI Reference](../../references/cli.md) diff --git a/contributing/concepts/engine/incremental.md b/contributing/concepts/engine/incremental.md new file mode 100644 index 00000000..185d3082 --- /dev/null +++ b/contributing/concepts/engine/incremental.md @@ -0,0 +1,99 @@ +# Incremental Sync + +Manifest-based skip logic that makes `zpress dev` fast after the initial sync. + +## Overview + +The engine tracks per-file metadata in a manifest to skip redundant work on subsequent syncs. Only changed pages go through the full read/transform/hash pipeline. This is what makes the dev watch loop responsive -- a single markdown edit triggers an incremental sync that skips all unchanged pages. + +## Skip Decision Tree + +```mermaid +%%{init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#313244', + 'primaryTextColor': '#cdd6f4', + 'primaryBorderColor': '#6c7086', + 'lineColor': '#89b4fa', + 'secondaryColor': '#45475a', + 'tertiaryColor': '#1e1e2e', + 'background': '#1e1e2e', + 'mainBkg': '#313244', + 'clusterBkg': '#1e1e2e', + 'clusterBorder': '#45475a' + }, + 'flowchart': { 'curve': 'basis', 'padding': 15 } +}}%% +flowchart TB + START(("copyPage()")) + VIRTUAL{"Virtual page?"} + MTIME{"Source mtime changed?"} + FMHASH{"Frontmatter hash changed?"} + SKIP_EARLY["Skip: mtime hit"] + READ(["Read + transform"]) + HASH{"Content hash changed?"} + WRITE["Write to disk"] + SKIP_HASH["Skip: hash hit"] + + START --> VIRTUAL + VIRTUAL -- "yes" --> READ + VIRTUAL -- "no" --> MTIME + MTIME -- "yes" --> READ + MTIME -- "no" --> FMHASH + FMHASH -- "yes" --> READ + FMHASH -- "no" --> SKIP_EARLY + READ --> HASH + HASH -- "yes" --> WRITE + HASH -- "no" --> SKIP_HASH + + classDef core fill:#313244,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4 + classDef agent fill:#313244,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4 + classDef gateway fill:#313244,stroke:#fab387,stroke-width:2px,color:#cdd6f4 + + class START,READ core + class VIRTUAL,MTIME,FMHASH,HASH gateway + class SKIP_EARLY,SKIP_HASH,WRITE agent +``` + +Pages with no previous manifest entry (new pages) and pages with missing `frontmatterHash` in the manifest (first sync after upgrade) always go through the full pipeline. + +## Skip Layers + +The manifest enables multiple skip layers, ordered cheapest to most expensive: + +| Check | What's skipped | Cost | +| --------------------------------------- | ----------------------------------- | ----------------------------- | +| Source `mtime` + frontmatter hash match | Entire read/transform/hash pipeline | `fs.stat` + MD5 comparison | +| Content hash unchanged (post-transform) | File write to disk | SHA-256 of transformed output | +| Asset config hash unchanged | All SVG generation | SHA-256 comparison | +| Image destination `mtime` >= source | `copyFile` for that image | `fs.stat` (1 syscall) | +| OpenAPI spec `mtime` unchanged | `SwaggerParser.dereference` | `fs.stat` (1 syscall) | + +## Structural Change Detection + +When the total page count (`resolvedCount`) changes between syncs, a structural change has occurred -- a page was added or removed. Mtime-based skipping is disabled for one pass to ensure all pages go through the full pipeline. This handles cases where a new page affects link rewriting or sidebar structure. + +After the full pass completes, the new `resolvedCount` is saved and mtime skipping resumes on the next sync. + +## Stale File Cleanup + +After every sync, files present in the old manifest but absent in the new one are removed from the output directory. Empty parent directories are pruned afterwards. + +## Manifest Shape + +The manifest (`sync/manifest.ts`) stores: + +| Field | Purpose | +| ----------------- | ------------------------------------------------------------------------------------------------------- | +| `files` | Per-page entries keyed by `outputPath`: `contentHash`, `sourceMtime`, `frontmatterHash` (MD5), `source` | +| `assetConfigHash` | SHA-256 of serialized asset config (title, tagline) | +| `openapiMtimes` | Per-spec mtime for OpenAPI skip checks | +| `resolvedCount` | Total page count for structural change detection | + +## References + +- [Engine Overview](./overview.md) +- [Pipeline](./pipeline.md) +- [OpenAPI Sync](./openapi.md) +- [Dev Mode](./dev.md) diff --git a/contributing/concepts/engine/openapi.md b/contributing/concepts/engine/openapi.md new file mode 100644 index 00000000..863884bc --- /dev/null +++ b/contributing/concepts/engine/openapi.md @@ -0,0 +1,69 @@ +# OpenAPI Sync + +Transforms OpenAPI specs into browsable MDX pages with interactive components. + +## Overview + +The OpenAPI sync (`sync/openapi.ts`) is a sub-pipeline bolted onto the main sync engine. It collects specs from the config, dereferences `$ref`s, generates one MDX page per operation, and builds sidebar items grouped by tag. + +```mermaid +%%{init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#313244', + 'primaryTextColor': '#cdd6f4', + 'primaryBorderColor': '#6c7086', + 'lineColor': '#89b4fa', + 'secondaryColor': '#45475a', + 'tertiaryColor': '#1e1e2e', + 'background': '#1e1e2e', + 'mainBkg': '#313244', + 'clusterBkg': '#1e1e2e', + 'clusterBorder': '#45475a' + }, + 'flowchart': { 'curve': 'basis', 'padding': 15 } +}}%% +flowchart LR + SPEC(["openapi.yaml"]) --> MTIME{"mtime changed?"} + MTIME -- "no + cached" --> CACHED(["Use cached spec"]) + MTIME -- "yes" --> DEREF(["Dereference $refs"]) + CACHED --> EXTRACT(["Extract operations"]) + DEREF --> EXTRACT + EXTRACT --> GROUP(["Group by tag"]) + GROUP --> MDX(["Generate MDX pages"]) + GROUP --> SIDEBAR(["Build sidebar items"]) + + classDef external fill:#313244,stroke:#f5c2e7,stroke-width:2px,color:#cdd6f4 + classDef core fill:#313244,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4 + classDef agent fill:#313244,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4 + classDef gateway fill:#313244,stroke:#fab387,stroke-width:2px,color:#cdd6f4 + + class SPEC external + class DEREF,EXTRACT,GROUP core + class MDX,SIDEBAR agent + class MTIME gateway + class CACHED agent +``` + +## Steps + +| # | Step | What it does | +| --- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| 1 | Collect configs | Gather OpenAPI configs from root `config.openapi` and entry-level `openapi` fields (`apps`, `packages`, and workspace items) | +| 2 | Check mtime | Stat the spec file and compare its mtime to the previous manifest to decide whether a shared cached dereference can be reused | +| 3 | Dereference | Resolve all `$ref`s via `@apidevtools/swagger-parser` (or use cached result) | +| 4 | Extract operations | Pull operations from paths, group by tag | +| 5 | Generate MDX | One `.mdx` per operation (renders ``) + overview page (``) | +| 6 | Build sidebar | Sidebar items grouped by tag with configurable layout (`method-path` or `title`) | +| 7 | Emit spec | Emit dereferenced spec as a virtual `openapi.json` page (written by the sync engine's copy step) | + +## Caching + +In dev mode, a shared `Map` is created once and threaded through all sync passes. See [Dev Mode -- OpenAPI Cache](./dev.md#openapi-cache) for lifecycle details. + +## References + +- [Engine Overview](./overview.md) +- [Pipeline](./pipeline.md) +- [Incremental Sync](./incremental.md) +- [Dev Mode](./dev.md) diff --git a/contributing/concepts/engine/overview.md b/contributing/concepts/engine/overview.md new file mode 100644 index 00000000..c8b57f94 --- /dev/null +++ b/contributing/concepts/engine/overview.md @@ -0,0 +1,103 @@ +# Engine + +The materialization layer that transforms `zpress.config.ts` into a Rspress-compatible documentation site. + +## Overview + +The engine reads a declarative config, discovers markdown files via globs, resolves the information architecture (sidebar, nav, landing pages), and writes everything into `.zpress/content/`. Rspress only consumes the engine's output in `.zpress/content/`. + +```mermaid +%%{init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#313244', + 'primaryTextColor': '#cdd6f4', + 'primaryBorderColor': '#6c7086', + 'lineColor': '#89b4fa', + 'secondaryColor': '#45475a', + 'tertiaryColor': '#1e1e2e', + 'background': '#1e1e2e', + 'mainBkg': '#313244', + 'clusterBkg': '#1e1e2e', + 'clusterBorder': '#45475a' + }, + 'flowchart': { 'curve': 'basis', 'padding': 15 } +}}%% +flowchart LR + subgraph input ["Input"] + CONFIG(["zpress.config.ts"]) + MD(["*.md / *.mdx"]) + SPECS(["openapi.yaml"]) + end + + subgraph engine ["Engine"] + RESOLVE(["resolve"]) + TRANSFORM(["transform"]) + GENERATE(["generate"]) + end + + subgraph output [".zpress/content/"] + CONTENT(["pages"]) + META([".generated/"]) + IMAGES(["images"]) + end + + CONFIG --> RESOLVE + MD --> TRANSFORM + SPECS --> GENERATE + RESOLVE --> TRANSFORM --> CONTENT + RESOLVE --> META + TRANSFORM --> IMAGES + GENERATE --> CONTENT + + classDef external fill:#313244,stroke:#f5c2e7,stroke-width:2px,color:#cdd6f4 + classDef core fill:#313244,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4 + classDef agent fill:#313244,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4 + + class CONFIG,MD,SPECS external + class RESOLVE,TRANSFORM,GENERATE core + class CONTENT,META,IMAGES agent + + style input fill:#181825,stroke:#f5c2e7,stroke-width:2px + style engine fill:#181825,stroke:#89b4fa,stroke-width:2px + style output fill:#181825,stroke:#a6e3a1,stroke-width:2px +``` + +## Key Concepts + +- **Config-driven** -- The config defines the entire information architecture. No separate sidebar or nav config files. +- **Glob-driven discovery** -- Patterns auto-discover files without manual entry per page. +- **Virtual pages** -- Landing pages and home pages are generated as MDX at sync time. +- **Multi-sidebar** -- Root entries share `/`, isolated sections use distinct namespaces (e.g., `/apps/api/`). +- **Incremental** -- Mtime checks, content hashes, and config hashes skip unchanged work between syncs. + +## Build vs Dev + +**Build** (`zpress build`) runs a single sync pass: + +```text +loadConfig() --> sync() --> createRspressConfig() --> rspress build() --> .zpress/dist/ +``` + +**Dev** (`zpress dev`) runs sync then enters a watch loop: + +```text +loadConfig() --> sync() --> createRspressConfig() --> rspress dev() --> watcher +``` + +After initial sync, the watcher monitors the repo and triggers incremental resyncs. See [Dev Mode](./dev.md) for how the watch loop works. + +## Topics + +| Topic | What it covers | +| ------------------------------------ | ----------------------------------------------------------------------- | +| [Pipeline](./pipeline.md) | The sync pipeline, page transformation, entry resolution, multi-sidebar | +| [Incremental Sync](./incremental.md) | Mtime-based skipping, content hashing, structural change detection | +| [OpenAPI Sync](./openapi.md) | Spec dereferencing, MDX generation, sidebar building | +| [Dev Mode](./dev.md) | File watching, debouncing, HMR, config reload, concurrency | + +## References + +- [Architecture](../architecture.md) +- [Config](../config.md) +- [CLI Reference](../../references/cli.md) diff --git a/contributing/concepts/engine/pipeline.md b/contributing/concepts/engine/pipeline.md new file mode 100644 index 00000000..bb816c9a --- /dev/null +++ b/contributing/concepts/engine/pipeline.md @@ -0,0 +1,186 @@ +# Pipeline + +The sync pipeline that transforms config into content. + +## Overview + +The `sync()` function in `packages/core/src/sync/index.ts` runs a four-phase pipeline: setup, resolve, write, and generate. This expands the three-phase model from the [Engine Overview](./overview.md) into the individual steps that run within each phase. + +```mermaid +%%{init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#313244', + 'primaryTextColor': '#cdd6f4', + 'primaryBorderColor': '#6c7086', + 'lineColor': '#89b4fa', + 'secondaryColor': '#45475a', + 'tertiaryColor': '#1e1e2e', + 'background': '#1e1e2e', + 'mainBkg': '#313244', + 'clusterBkg': '#1e1e2e', + 'clusterBorder': '#45475a' + }, + 'flowchart': { 'curve': 'basis', 'padding': 15 } +}}%% +flowchart TB + subgraph setup ["Setup"] + S1(["1. Create dirs"]) + S2(["2. Load manifest"]) + S3(["3. Generate assets"]) + S4(["4. Copy public/"]) + end + + subgraph resolve ["Resolve"] + S5(["5. Synthesize workspaces"]) + S6(["6. Resolve entries"]) + S7(["7. Enrich cards"]) + S8(["8. Inject landing pages"]) + S9(["9. Collect pages"]) + S10(["10. Write workspace data"]) + S11(["11. Generate home"]) + S12(["12. Discover planning pages"]) + S13(["13. OpenAPI sync"]) + end + + subgraph write ["Write"] + S14(["14. Copy pages"]) + S15(["15. Clean stale files"]) + end + + subgraph generate ["Generate"] + S16(["16. Build sidebar + nav"]) + S17(["17. Save manifest"]) + S18(["18. Write README"]) + end + + setup --> resolve --> write --> generate + + classDef core fill:#313244,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4 + classDef gateway fill:#313244,stroke:#fab387,stroke-width:2px,color:#cdd6f4 + classDef agent fill:#313244,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4 + classDef external fill:#313244,stroke:#f5c2e7,stroke-width:2px,color:#cdd6f4 + + class S1,S2,S3,S4 external + class S5,S6,S7,S8,S9,S10,S11,S12,S13 core + class S14,S15 gateway + class S16,S17,S18 agent + + style setup fill:#181825,stroke:#f5c2e7,stroke-width:2px + style resolve fill:#181825,stroke:#89b4fa,stroke-width:2px + style write fill:#181825,stroke:#fab387,stroke-width:2px + style generate fill:#181825,stroke:#a6e3a1,stroke-width:2px +``` + +## Steps + +| # | Step | Phase | What it does | +| --- | ----------------------- | -------- | ------------------------------------------------------------------------------------------- | +| 1 | Create dirs | Setup | Create `.zpress/content/`, `.zpress/content/.generated/`, and `.zpress/public/` directories | +| 2 | Load manifest | Setup | Load previous sync manifest for incremental diffing | +| 3 | Generate assets | Setup | Generate branded SVG assets (conditional -- skipped when asset config hash unchanged) | +| 4 | Copy public/ | Setup | Copy public assets into `.zpress/public/` for Rspress | +| 5 | Synthesize workspaces | Resolve | Convert `apps`/`packages`/`workspaces` into entry sections | +| 6 | Resolve entries | Resolve | Walk config tree, resolve globs, derive text, merge frontmatter | +| 7 | Enrich cards | Resolve | Attach workspace metadata (icon, scope, tags, badge) | +| 8 | Inject landing pages | Resolve | Generate virtual MDX for sections with children but no page | +| 9 | Collect pages | Resolve | Flatten the resolved tree into a flat page list | +| 10 | Write workspace data | Resolve | Serialize workspace metadata to `.zpress/content/.generated/workspaces.json` | +| 11 | Generate home | Resolve | Create default home page from config metadata (when no explicit index.md) | +| 12 | Discover planning pages | Resolve | Resolve pages from `.planning/` directory (included in output, excluded from sidebar/nav) | +| 13 | OpenAPI sync | Resolve | Dereference specs, generate `.mdx` per operation | +| 14 | Copy pages | Write | Write pages with injected frontmatter, rewrite links, track hashes (parallel) | +| 15 | Clean stale files | Write | Remove files present in old manifest but absent in new; prune empty directories | +| 16 | Build sidebar + nav | Generate | Build multi-sidebar JSON and nav array | +| 17 | Save manifest | Generate | Record file hashes and incremental metadata | +| 18 | Write README | Generate | Write bare-bones README to `.zpress/` root | + +Returns: `{ pagesWritten, pagesSkipped, pagesRemoved, elapsed }` (elapsed in milliseconds) + +## Page Transformation + +Each page passes through `copyPage()` (`sync/copy.ts`): + +```mermaid +%%{init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#313244', + 'primaryTextColor': '#cdd6f4', + 'primaryBorderColor': '#6c7086', + 'lineColor': '#89b4fa', + 'secondaryColor': '#45475a', + 'tertiaryColor': '#1e1e2e', + 'background': '#1e1e2e', + 'mainBkg': '#313244', + 'clusterBkg': '#1e1e2e', + 'clusterBorder': '#45475a' + }, + 'flowchart': { 'curve': 'basis', 'padding': 15 } +}}%% +flowchart LR + SKIP{"Mtime skip?"} + READ(["Read source"]) + LINKS(["Rewrite links"]) + IMAGES(["Copy images"]) + FM(["Merge frontmatter"]) + HASH{"Hash changed?"} + WRITE(["Write output"]) + CACHED(["Return cached"]) + NOOP(["Skip write"]) + + SKIP -- "hit" --> CACHED + SKIP -- "miss" --> READ --> LINKS --> IMAGES --> FM --> HASH + HASH -- "yes" --> WRITE + HASH -- "no" --> NOOP + + classDef core fill:#313244,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4 + classDef agent fill:#313244,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4 + classDef gateway fill:#313244,stroke:#fab387,stroke-width:2px,color:#cdd6f4 + + class READ,LINKS,IMAGES,FM core + class SKIP,HASH gateway + class WRITE,CACHED,NOOP agent +``` + +1. **Mtime skip check** -- If source mtime and frontmatter hash match the previous manifest, return the cached entry (see [Incremental Sync](./incremental.md)) +2. Read source file (or evaluate virtual content) +3. Rewrite relative markdown links using source-to-output path map +4. Copy referenced images to `content/public/images/`, rewrite paths to `/images/-.` (8-char MD5 of the image path) +5. Merge frontmatter (config defaults + source frontmatter, source wins) +6. SHA-256 content hash -- skip write if unchanged from previous manifest +7. Write final markdown/MDX to `.zpress/content/` + +## Entry Resolution + +The entry resolver (`sync/resolve/index.ts`) recursively walks the config tree and resolves each entry: + +| Entry type | Description | Example | +| -------------- | ------------------------------ | ------------------------------------------- | +| Single file | Source file with explicit link | `include: 'docs/getting-started.md'` | +| Virtual page | Generated content | `content: () => '# Hello'` | +| Glob section | Pattern that discovers files | `include: 'docs/guides/*.md'` | +| Recursive glob | Directory-driven nesting | `include: 'docs/**/*.md', recursive: true` | +| Explicit items | Hand-written child entries | `items: [{ title: '...', include: '...' }]` | + +### Text Derivation + +Text derivation is configurable via the `title` field's `from` property: + +| Value | Source | +| --------------- | --------------------------------------------------------------------- | +| `'auto'` | Frontmatter title, then first `#` heading, then filename (default) | +| `'filename'` | Kebab-case filename to title case | +| `'heading'` | First `#` heading in the markdown file | +| `'frontmatter'` | `title` from YAML frontmatter, falling back to heading, then filename | + +## Multi-Sidebar + +zpress generates a multi-sidebar structure for Rspress. Root entries share the `/` namespace. Isolated sections (workspace items, explicit `isolated: true`) get their own namespace (e.g., `/apps/api/`). Each isolated section has an independent sidebar tree. + +## References + +- [Engine Overview](./overview.md) +- [Incremental Sync](./incremental.md) +- [OpenAPI Sync](./openapi.md) +- [Dev Mode](./dev.md) diff --git a/contributing/guides/getting-started.md b/contributing/guides/getting-started.md index 26e7e308..55cf30a6 100644 --- a/contributing/guides/getting-started.md +++ b/contributing/guides/getting-started.md @@ -50,7 +50,7 @@ Read the project docs in this order: 1. `CLAUDE.md` (repo root) -- tech stack, project structure, available commands 2. [`contributing/concepts/architecture.md`](../concepts/architecture.md) -- packages, sync engine, and data flow -3. [`contributing/concepts/cli.md`](../concepts/cli.md) -- commands, dev server, and build pipeline +3. [`contributing/concepts/engine/overview.md`](../concepts/engine/overview.md) -- sync engine, build vs dev, key concepts 4. Relevant standards in the [Contributing](../README.md) overview as needed ### 6. Set up Claude Code (optional) diff --git a/contributing/references/cli.md b/contributing/references/cli.md new file mode 100644 index 00000000..cf978764 --- /dev/null +++ b/contributing/references/cli.md @@ -0,0 +1,132 @@ +# CLI Reference + +Command syntax, flags, and Rspress integration for the `zpress` CLI. + +## Overview + +zpress uses [`@kidd-cli/core`](https://github.com/kidd-framework/kidd-cli) for command routing and `@kidd-cli/core/logger` for styled terminal output. The CLI entry point is in `packages/cli/src/`, which registers all commands. Each command is a standalone module that orchestrates the engine and Rspress build APIs. + +## Commands + +| Command | Description | +| ------- | --------------------------------------------------- | +| `setup` | Create a starter `zpress.config.ts` | +| `dev` | Sync + Rspress dev server + file watcher | +| `build` | Sync + Rspress static build | +| `serve` | Preview a built site from `.zpress/dist/` | +| `check` | Validate config and check for broken links | +| `diff` | Show changed files in configured source directories | +| `draft` | Scaffold a new documentation file from a template | +| `clean` | Remove `.zpress/cache/`, `content/`, and `dist/` | +| `dump` | Resolve the full entry tree and print as JSON | + +### `setup` + +```bash +zpress setup +``` + +Creates a starter `zpress.config.ts` in the current directory if one does not already exist. + +### `dev` + +```bash +zpress dev [--quiet] [--clean] [--port ] [--theme ] [--colorMode ] [--vscode] +``` + +The primary development workflow. Combines initial sync, file watcher, and Rspress dev server on `http://localhost:6174`. The `--clean` flag removes cache, content, and dist before starting. + +See [Dev Mode](../concepts/engine/dev.md) for how the watch loop, HMR, and config reload work. + +### `build` + +```bash +zpress build [--quiet] [--clean] [--check] [--no-check] [--verbose] +``` + +Produces a static site: + +1. Optional clean step +2. Full sync +3. Rspress build (generates optimized HTML/CSS/JS in `.zpress/dist/`) +4. Link check (enabled by default, disable with `--no-check`) + +### `serve` + +```bash +zpress serve [--no-open] [--port ] [--theme ] [--colorMode ] [--vscode] +``` + +Starts a static file server pointing at `.zpress/dist/` on `http://localhost:8080`. The browser opens automatically; use `--no-open` to disable. + +### `check` + +```bash +zpress check +``` + +Validates the config and runs a build to detect broken links. Reports config errors and deadlinks with a summary table. + +### `diff` + +```bash +zpress diff [--ref ] +``` + +Shows changed files in configured source directories. By default uses `git status` to detect uncommitted changes and outputs a space-separated file list to stdout (suitable for scripts and piping). + +Use `--ref ` to compare between commits (`git diff --name-only HEAD`). Exits with code 1 when changes are detected -- matching the Vercel `ignoreCommand` convention (exit 1 = proceed with build, exit 0 = skip). + +### `draft` + +```bash +zpress draft [--type ] [--title ] [--out <dir>] +``` + +Scaffolds a new documentation file from a template. Prompts for doc type and title when not provided via args, then writes the rendered template to the specified output directory. + +### `clean` + +```bash +zpress clean +``` + +Removes `.zpress/cache/`, `content/`, and `dist/`. Safe to run at any time -- all content is regenerated by sync/build. + +### `dump` + +```bash +zpress dump +``` + +Resolves the full entry tree from the config and prints it as JSON. Useful for debugging config resolution and glob patterns. + +## Rspress Integration + +The CLI communicates with Rspress through `packages/cli/src/lib/rspress.ts`: + +| Function | Purpose | +| --------------------- | -------------------------------------------- | +| `startDevServer()` | Launch Rspress dev server on port 6174 | +| `buildSite()` | Run Rspress static build to `.zpress/dist/` | +| `buildSiteForCheck()` | Build with deadlink detection enabled | +| `serveSite()` | Start static file server for `.zpress/dist/` | +| `openBrowser()` | Cross-platform browser launcher | + +All functions receive a Rspress config object built by `createRspressConfig()` from `@zpress/ui`. This config loads the generated JSON files (sidebar, nav, workspaces) from `.zpress/content/.generated/` and wires up the zpress theme. + +## Error Handling + +CLI errors are handled at the command boundary: + +- **Config errors** -- `loadConfig()` returns a Result tuple; commands report errors via `@kidd-cli/core/logger` and call `process.exit(1)` +- **Sync errors** -- Result tuples propagate up; the CLI reports them and exits +- **Rspress errors** -- Build/dev failures are caught and reported + +All user-facing error formatting is centralized in the CLI layer. + +## References + +- [Architecture](../concepts/architecture.md) +- [Engine Overview](../concepts/engine/overview.md) +- [Dev Mode](../concepts/engine/dev.md) diff --git a/package.json b/package.json index 3119912b..264b683b 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "docs:build": "node packages/zpress/dist/cli.mjs build", "docs:serve": "node packages/zpress/dist/cli.mjs serve", "docs:generate": "lauf run docs", - "dev": "turbo run dev" + "dev": "turbo run dev", + "stories": "pnpm --filter @zpress/cli stories" }, "dependencies": { "@zpress/kit": "workspace:*" diff --git a/packages/cli/kidd.config.ts b/packages/cli/kidd.config.ts new file mode 100644 index 00000000..72d61117 --- /dev/null +++ b/packages/cli/kidd.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@kidd-cli/core' + +export default defineConfig({ + commands: './src/commands', + entry: './src/index.ts', +}) diff --git a/packages/cli/package.json b/packages/cli/package.json index b6206ecb..2f698713 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,24 +34,28 @@ "provenance": true }, "scripts": { - "build": "rslib build", - "dev": "node ./dist/index.mjs dev --cwd ../..", + "build": "kidd build", + "dev": "kidd dev", + "stories": "kidd stories", "test": "vitest run", "typecheck": "tsgo --noEmit" }, "dependencies": { - "@kidd-cli/core": "^0.13.0", + "@kidd-cli/core": "^0.22.1", "@rspress/core": "catalog:", "@zpress/core": "workspace:*", "@zpress/templates": "workspace:*", "@zpress/ui": "workspace:*", "es-toolkit": "catalog:", "get-port": "^7.2.0", + "ink": "^6.8.0", + "react": "^19.2.0", "ts-pattern": "catalog:", "zod": "catalog:" }, "devDependencies": { - "@rslib/core": "catalog:", + "@kidd-cli/cli": "^0.11.2", + "@types/react": "^19.1.0", "typescript": "catalog:", "vitest": "catalog:" }, diff --git a/packages/cli/rslib.config.ts b/packages/cli/rslib.config.ts deleted file mode 100644 index b4a45e6b..00000000 --- a/packages/cli/rslib.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createRequire } from 'node:module' - -import { defineConfig } from '@rslib/core' - -const require = createRequire(import.meta.url) -const pkg = require('./package.json') - -export default defineConfig({ - lib: [ - { - format: 'esm', - bundle: true, - syntax: 'esnext', - autoExtension: false, - autoExternal: true, - dts: { bundle: true }, - source: { - entry: { - index: './src/index.ts', - }, - define: { - ZPRESS_VERSION: JSON.stringify(pkg.version), - }, - }, - output: { - filename: { - js: '[name].mjs', - }, - }, - }, - ], - output: { - target: 'node', - cleanDistPath: true, - }, -}) diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index e0eebe6e..8788e0ed 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -16,7 +16,8 @@ import { clean } from './clean.ts' * detection run as part of the build. Use `--no-check` to skip checks * and build with standard (noisy) Rspress output. */ -export const buildCommand = command({ +export default command({ + name: 'build', description: 'Sync content, generate assets, and build the site', options: z.object({ quiet: z.boolean().optional().default(false), diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 49e41479..5de084dc 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -6,7 +6,8 @@ import { presentResults, runBuildCheck, runConfigCheck } from '../lib/check.ts' /** * Registers the `check` CLI command to validate config and detect deadlinks. */ -export const checkCommand = command({ +export default command({ + name: 'check', description: 'Validate config and check for broken links', handler: async (ctx) => { const paths = createPaths(process.cwd()) diff --git a/packages/cli/src/commands/clean.ts b/packages/cli/src/commands/clean.ts index 6f26da38..d2dbb172 100644 --- a/packages/cli/src/commands/clean.ts +++ b/packages/cli/src/commands/clean.ts @@ -34,7 +34,8 @@ export async function clean(paths: Paths): Promise<readonly string[]> { /** * Registers the `clean` CLI command to remove build artifacts and caches. */ -export const cleanCommand = command({ +export default command({ + name: 'clean', description: 'Remove build artifacts, synced content, and build cache', handler: async (ctx) => { const paths = createPaths(process.cwd()) diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index f7c7d598..3483efa1 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -1,15 +1,16 @@ -import { command } from '@kidd-cli/core' -import { createPaths, loadConfig, sync } from '@zpress/core' +import { screen } from '@kidd-cli/core/ui' import { z } from 'zod' -import { startDevServer } from '../lib/rspress.ts' -import { clean } from './clean.ts' +import { DevScreen } from '../screens/dev-screen.tsx' /** * Registers the `dev` CLI command to sync, watch, and start a live dev server. */ -export const devCommand = command({ +export default screen({ + name: 'dev', description: 'Run sync + watcher and start Rspress dev server', + exit: 'manual', + fullscreen: true, options: z.object({ quiet: z.boolean().optional().default(false), clean: z.boolean().optional().default(false), @@ -18,53 +19,5 @@ export const devCommand = command({ colorMode: z.string().optional(), vscode: z.boolean().optional().default(false), }), - handler: async (ctx) => { - const { quiet } = ctx.args - const paths = createPaths(process.cwd()) - ctx.log.intro('zpress dev') - - if (ctx.args.clean) { - const removed = await clean(paths) - if (removed.length > 0 && !quiet) { - ctx.log.info(`Cleaned: ${removed.join(', ')}`) - } - } - - const [configErr, config] = await loadConfig(paths.repoRoot) - if (configErr) { - ctx.log.error(configErr.message) - if (configErr.errors && configErr.errors.length > 0) { - // oxlint-disable-next-line unicorn/no-array-for-each -- side-effect: logging each validation error - configErr.errors.forEach((err) => { - const path = err.path.join('.') - ctx.log.error(` ${path}: ${err.message}`) - }) - } - process.exit(1) - } - - // Initial sync - await sync(config, { paths, quiet }) - - // Start Rspress dev server and get config reload callback - const onConfigReload = await startDevServer({ - config, - paths, - port: ctx.args.port, - theme: ctx.args.theme, - colorMode: ctx.args.colorMode, - vscode: ctx.args.vscode, - }) - - // Start watcher with config reload callback - const { createWatcher } = await import('../lib/watcher.ts') - const watcher = createWatcher({ initialConfig: config, paths, log: ctx.log, onConfigReload }) - - function cleanup(): void { - watcher.close() - } - - process.on('SIGINT', cleanup) - process.on('SIGTERM', cleanup) - }, + render: DevScreen, }) diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index 7ffee261..ab3a7ee0 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -43,7 +43,8 @@ const CONFIG_GLOBS = [ * zpress diff --ref main --pretty * ``` */ -export const diffCommand = command({ +export default command({ + name: 'diff', description: 'Show changed files in configured source directories', options: z.object({ pretty: z.boolean().optional().default(false), diff --git a/packages/cli/src/commands/draft.ts b/packages/cli/src/commands/draft.ts index a01ecd29..a617aeff 100644 --- a/packages/cli/src/commands/draft.ts +++ b/packages/cli/src/commands/draft.ts @@ -15,7 +15,8 @@ const registry = createRegistry() * Prompts for the doc type and title when not provided via args, * then writes the rendered template to the specified output directory. */ -export const draftCommand = command({ +export default command({ + name: 'draft', description: 'Scaffold a new documentation file from a template', options: z.object({ type: z.string().optional(), diff --git a/packages/cli/src/commands/dump.ts b/packages/cli/src/commands/dump.ts index 58b9ee45..8342bf4b 100644 --- a/packages/cli/src/commands/dump.ts +++ b/packages/cli/src/commands/dump.ts @@ -19,7 +19,8 @@ interface DumpEntry { * * @returns A CLI command that outputs the resolved entry tree as formatted JSON */ -export const dumpCommand = command({ +export default command({ + name: 'dump', description: 'Resolve and print the full entry tree as JSON', handler: async (ctx) => { const paths = createPaths(process.cwd()) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 4112cece..3e894508 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -7,7 +7,8 @@ import { openBrowser, serveSite } from '../lib/rspress.ts' /** * Registers the `serve` CLI command to preview a previously built site. */ -export const serveCommand = command({ +export default command({ + name: 'serve', description: 'Preview the built Rspress site', options: z.object({ open: z.boolean().optional().default(true), diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 843cf8ef..2e4fc20b 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -16,7 +16,8 @@ const CONFIG_FILENAME = 'zpress.config.ts' * to the directory name), writes a starter `zpress.config.ts`, * and generates initial banner/logo assets. */ -export const setupCommand = command({ +export default command({ + name: 'setup', description: 'Initialize a zpress config in the current project', handler: async (ctx) => { const cwd = process.cwd() diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6d8ca2bb..21a9d5d1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env node /* |========================================================================== | zpress CLI @@ -13,34 +12,24 @@ import './shims/require.ts' import { cli } from '@kidd-cli/core' -import { buildCommand } from './commands/build.ts' -import { checkCommand } from './commands/check.ts' -import { cleanCommand } from './commands/clean.ts' -import { devCommand } from './commands/dev.ts' -import { diffCommand } from './commands/diff.ts' -import { draftCommand } from './commands/draft.ts' -import { dumpCommand } from './commands/dump.ts' -import { serveCommand } from './commands/serve.ts' -import { setupCommand } from './commands/setup.ts' +import build from './commands/build.ts' +import check from './commands/check.ts' +import cleanCmd from './commands/clean.ts' +import dev from './commands/dev.ts' +import diff from './commands/diff.ts' +import draft from './commands/draft.ts' +import dump from './commands/dump.ts' +import serve from './commands/serve.ts' +import setup from './commands/setup.ts' -declare const ZPRESS_VERSION: string +declare const __KIDD_VERSION__: string await cli({ name: 'zpress', - version: ZPRESS_VERSION, + version: __KIDD_VERSION__, description: 'CLI for building and serving documentation', - commands: { - commands: { - setup: setupCommand, - dev: devCommand, - build: buildCommand, - serve: serveCommand, - check: checkCommand, - diff: diffCommand, - draft: draftCommand, - clean: cleanCommand, - dump: dumpCommand, - }, + commands: { build, check, clean: cleanCmd, dev, diff, draft, dump, serve, setup }, + help: { order: ['setup', 'dev', 'build', 'serve', 'check', 'diff', 'draft', 'clean', 'dump'], }, }) diff --git a/packages/cli/src/lib/dev-types.ts b/packages/cli/src/lib/dev-types.ts new file mode 100644 index 00000000..b1d7f1e0 --- /dev/null +++ b/packages/cli/src/lib/dev-types.ts @@ -0,0 +1,29 @@ +import type { SyncResult } from '@zpress/core' + +/** + * Discriminated union representing the current state of the file watcher. + */ +export type WatcherStatus = + | { readonly _tag: 'idle' } + | { readonly _tag: 'syncing'; readonly isConfigReload: boolean } + | { readonly _tag: 'restarting' } + | { readonly _tag: 'error'; readonly message: string } + +/** + * Callback interface used by the watcher to communicate state changes + * to the TUI layer without coupling to a specific logging implementation. + */ +export interface WatcherCallbacks { + readonly onStatusChange: (status: WatcherStatus) => void + readonly onSyncComplete: (result: SyncResult) => void + readonly onFileChange: (filename: string) => void + readonly onConfigReloaded: () => void +} + +/** + * Closeable + resyncable handle returned by createWatcher. + */ +export interface WatcherHandle { + readonly close: () => void + readonly resync: () => void +} diff --git a/packages/cli/src/lib/rspress.ts b/packages/cli/src/lib/rspress.ts index 553de48a..2669d95b 100644 --- a/packages/cli/src/lib/rspress.ts +++ b/packages/cli/src/lib/rspress.ts @@ -45,7 +45,9 @@ interface ServerInstance { * Internal options for `startServer` that control rebuild behaviour. */ interface StartServerOptions { - /** When true, disables the persistent build cache for this invocation. */ + /** + * When true, disables the persistent build cache for this invocation. + */ readonly skipBuildCache: boolean } @@ -54,19 +56,25 @@ interface StartServerOptions { */ export type OnConfigReload = (newConfig: ZpressConfig) => Promise<void> +/** + * Result returned by `startDevServer` containing the reload callback and resolved port. + */ +export interface DevServerResult { + readonly onConfigReload: OnConfigReload + readonly port: number +} + /** * Start the Rspress dev server with zpress configuration. * - * Returns a callback that will restart the server when invoked with updated config. - * The callback closes the current server instance and starts a new one with the - * fresh configuration values. + * Returns the resolved port and a callback that will restart the server when + * invoked with updated config. The callback closes the current server instance + * and starts a new one with the fresh configuration values. * * @param options - Dev server configuration including config and paths - * @returns An async callback to invoke when config changes with new config (restarts server) + * @returns The resolved port and an async reload callback */ -export async function startDevServer( - options: ServerOptions -): Promise<(newConfig: ZpressConfig) => Promise<void>> { +export async function startDevServer(options: ServerOptions): Promise<DevServerResult> { const { paths } = options // Resolve port once so restarts reuse the same port const preferred = options.port ?? DEV_PORT @@ -81,6 +89,7 @@ export async function startDevServer( const rspressConfig = createRspressConfig({ config, paths, + logLevel: 'silent', vscode: options.vscode, themeOverride: options.theme, colorModeOverride: options.colorMode, @@ -96,6 +105,10 @@ export async function startDevServer( port, strictPort: true, }, + dev: { + // Suppress Rsbuild's progress bar — zpress TUI renders its own status + progressBar: false, + }, // Disable persistent build cache on config-reload restarts. // Rspress's cacheDigest only covers sidebar/nav structure, // so changes to title, theme, colors, source.define values @@ -116,8 +129,8 @@ export async function startDevServer( process.exit(1) } - // Return callback that restarts server with new config - return async (newConfig: ZpressConfig) => { + // Return resolved port and callback that restarts server with new config + async function handleConfigReload(newConfig: ZpressConfig): Promise<void> { process.stdout.write('\n🔄 Config changed — restarting dev server...\n') // Close existing server and wait for port release @@ -142,6 +155,14 @@ export async function startDevServer( ]) } serverInstance = null + + // Rspack's file-based storage layer (rspack_storage) holds a transaction + // lock on .temp/ inside the cache directory. Rsbuild's close() resolves + // before that lock is fully released. Without this settle window the new + // dev() call panics with "Transaction already in progress". + const RSPACK_SETTLE_MS = 500 + // oxlint-disable-next-line no-promise-executor-return -- settle delay is intentional + await new Promise((resolve) => setTimeout(resolve, RSPACK_SETTLE_MS)) } // Start new server with fresh config (bypass persistent cache) @@ -152,6 +173,8 @@ export async function startDevServer( process.stderr.write('⚠️ Dev server failed to restart — fix the config and save again\n\n') } } + + return { onConfigReload: handleConfigReload, port } } /** diff --git a/packages/cli/src/lib/watcher.ts b/packages/cli/src/lib/watcher.ts index a2038e78..dfdb8edc 100644 --- a/packages/cli/src/lib/watcher.ts +++ b/packages/cli/src/lib/watcher.ts @@ -1,11 +1,12 @@ +import { createHash } from 'node:crypto' import { watch } from 'node:fs' import path from 'node:path' -import type { Log } from '@kidd-cli/core' import type { ZpressConfig, Paths } from '@zpress/core' import { loadConfig, sync } from '@zpress/core' import { debounce } from 'es-toolkit' +import type { WatcherCallbacks, WatcherHandle } from './dev-types.ts' import { toError } from './error' const CONFIG_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json'] as const @@ -18,13 +19,6 @@ const MARKDOWN_EXTENSIONS = ['.md', '.mdx'] as const */ const IGNORED_DIRS = new Set(['node_modules', '.git', '.zpress', 'bundle', 'dist', '.turbo']) -/** - * Closeable handle returned by createWatcher. - */ -interface WatcherHandle { - close(): void -} - /** * Create a file watcher that re-syncs documentation on changes. * @@ -36,23 +30,25 @@ interface WatcherHandle { * @param params - Watcher configuration * @param params.initialConfig - Initial zpress config to use for syncing * @param params.paths - Resolved project paths - * @param params.log - Logger instance for status output + * @param params.callbacks - Callbacks for status changes, sync results, and file changes * @param params.onConfigReload - Optional async callback invoked after config reload and sync complete, receives new config - * @returns Closeable watcher handle + * @param params.openapiCache - Optional shared cache of dereferenced OpenAPI specs + * @returns Closeable and resyncable watcher handle */ export function createWatcher(params: { readonly initialConfig: ZpressConfig readonly paths: Paths - readonly log: Log + readonly callbacks: WatcherCallbacks readonly onConfigReload?: (newConfig: ZpressConfig) => Promise<void> + readonly openapiCache?: Map<string, unknown> }): WatcherHandle { - const { initialConfig, paths, log, onConfigReload } = params + const { initialConfig, paths, callbacks, onConfigReload, openapiCache } = params const { repoRoot } = paths const configFileNames = new Set(CONFIG_EXTENSIONS.map((ext) => `zpress.config${ext}`)) // oxlint-disable-next-line functional/no-let -- mutable config reloaded on file changes let config = initialConfig - log.info(`Watching ${repoRoot}`) + callbacks.onStatusChange({ _tag: 'idle' }) // oxlint-disable-next-line functional/no-let -- mutable sync state for debounced watcher let syncing = false @@ -68,41 +64,48 @@ export function createWatcher(params: { return } syncing = true + callbacks.onStatusChange({ _tag: 'syncing', isConfigReload: reloadConfig }) // oxlint-disable-next-line functional/no-let -- tracks whether this sync included a config reload let didReloadConfig = false + const previousConfig = config try { if (reloadConfig) { const [configErr, newConfig] = await loadConfig(paths.repoRoot) if (configErr) { - log.error(`Config reload failed: ${configErr.message}`) - if (configErr.errors && configErr.errors.length > 0) { - // oxlint-disable-next-line unicorn/no-array-for-each -- side-effect: logging each validation error - configErr.errors.forEach((err) => { - const pathStr = err.path.join('.') - log.error(` ${pathStr}: ${err.message}`) - }) - } + callbacks.onStatusChange({ + _tag: 'error', + message: `Config reload failed: ${configErr.message}`, + }) return } config = newConfig - log.info('Config reloaded') didReloadConfig = true + if (openapiCache) { + openapiCache.clear() + } } - await sync(config, { paths }) + const result = await sync(config, { paths, quiet: true, openapiCache }) consecutiveFailures = 0 - if (didReloadConfig && onConfigReload) { + callbacks.onSyncComplete(result) + // Only restart the dev server when restart-relevant fields changed. + // Sidebar/nav changes are picked up by Rspress via _meta.json/_nav.json HMR. + if (didReloadConfig && onConfigReload && needsServerRestart(previousConfig, config)) { + callbacks.onStatusChange({ _tag: 'restarting' }) await onConfigReload(config) + callbacks.onConfigReloaded() } + callbacks.onStatusChange({ _tag: 'idle' }) } catch (error) { consecutiveFailures += 1 - log.error(`Sync error: ${toError(error).message}`) + callbacks.onStatusChange({ _tag: 'error', message: `Sync error: ${toError(error).message}` }) } finally { syncing = false if (pendingReloadConfig !== null) { if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { - log.error( - `Sync failed ${consecutiveFailures} consecutive times, dropping pending resync. Will retry on next file change.` - ) + callbacks.onStatusChange({ + _tag: 'error', + message: `Sync failed ${consecutiveFailures} consecutive times, dropping pending resync. Will retry on next file change.`, + }) pendingReloadConfig = null consecutiveFailures = 0 } else { @@ -147,7 +150,7 @@ export function createWatcher(params: { const basename = path.basename(filename) if (isConfigFile(basename, filename)) { - log.info(`Config changed: ${basename}`) + callbacks.onFileChange(basename) debouncedConfigSync() return } @@ -156,14 +159,19 @@ export function createWatcher(params: { return } - log.step(`Changed: ${filename}`) + callbacks.onFileChange(filename) debouncedSync() }) return { close() { + debouncedSync.cancel() + debouncedConfigSync.cancel() watcher.close() }, + resync() { + triggerSync(false) + }, } } @@ -192,3 +200,45 @@ function isMarkdownFile(filePath: string): boolean { function isIgnored(filePath: string): boolean { return filePath.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)) } + +/** + * Check whether a config change requires a full dev server restart. + * + * Sidebar/nav structure changes are handled by Rspress HMR via `_meta.json` + * and `_nav.json` files. Only changes to fields that affect `source.define`, + * theme CSS, or other Rsbuild-level config require a restart. + * + * @private + * @param prev - Previous zpress config + * @param next - New zpress config after reload + * @returns True if the server must restart + */ +function needsServerRestart(prev: ZpressConfig, next: ZpressConfig): boolean { + return restartRelevantHash(prev) !== restartRelevantHash(next) +} + +/** + * Hash the config fields that require a server restart when changed. + * + * Excludes `sections`, `nav`, `apps`, `packages`, and `workspaces` since + * those only affect sidebar/nav structure handled by `_meta.json`/`_nav.json`. + * + * @private + * @param config - Zpress config to hash + * @returns SHA-256 hex digest of restart-relevant fields + */ +function restartRelevantHash(config: ZpressConfig): string { + const relevant = { + title: config.title, + description: config.description, + tagline: config.tagline, + icon: config.icon, + theme: config.theme, + sidebar: config.sidebar, + socialLinks: config.socialLinks, + footer: config.footer, + home: config.home, + openapi: config.openapi, + } + return createHash('sha256').update(JSON.stringify(relevant)).digest('hex') +} diff --git a/packages/cli/src/screens/dev-screen.tsx b/packages/cli/src/screens/dev-screen.tsx new file mode 100644 index 00000000..134fa1c8 --- /dev/null +++ b/packages/cli/src/screens/dev-screen.tsx @@ -0,0 +1,446 @@ +import { + Alert, + Box, + Spacer, + Spinner, + Text, + useApp, + useFullScreen, + useHotkey, + useInput, +} from '@kidd-cli/core/ui' +import type { SyncResult } from '@zpress/core' +import { createPaths, loadConfig, sync } from '@zpress/core' +import { useCallback, useEffect, useRef, useState } from 'react' +import { match } from 'ts-pattern' + +import type { WatcherCallbacks, WatcherHandle, WatcherStatus } from '../lib/dev-types.ts' +import { toError } from '../lib/error.ts' +import { openBrowser, startDevServer } from '../lib/rspress.ts' +import { createWatcher } from '../lib/watcher.ts' +import { clean } from '../commands/clean.ts' + +const isTTY = Boolean(process.stdin.isTTY) + +const MAX_LOG_ENTRIES = 50 + +/** + * ASCII Shadow banner for the dev screen header. + */ +const BANNER = [ + ' ██████╗ ██████╗ ███████╗███████╗███████╗', + ' ██╔══█ ██╔══██╗██╔══██╗██╔════╝██╔════╝██╔════╝', + ' ╚═══█║ ██████╔╝██████╔╝█████╗ ███████╗███████╗', + ' █╗ ██ ██╔═══╝ ██╔══██╗██╔══╝ ╚════██║╚════██║', + ' ╚█████ ██║ ██║ ██║███████╗███████║███████║', + ' ╚════╝╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝', +] + +/** + * A single entry in the activity log. + */ +export interface LogEntry { + readonly timestamp: string + readonly action: 'synced' | 'removed' | 'restarted' | 'error' + readonly file: string + readonly elapsed: number +} + +/** + * Props passed to the DevScreen component by the screen() runtime. + * These correspond to the parsed CLI options. + */ +interface DevScreenProps { + readonly quiet?: boolean + readonly clean?: boolean + readonly port?: number + readonly theme?: string + readonly colorMode?: string + readonly vscode?: boolean +} + +/** + * React/Ink TUI for the `zpress dev` command. + * + * Renders a fullscreen status display with ASCII banner, activity log, + * sync stats, and hotkey bar. + * + * @param props - Parsed CLI options + * @returns React element tree for the dev TUI + */ +export function DevScreen(props: DevScreenProps): React.ReactElement { + const { exit } = useApp() + const { columns } = useFullScreen() + + const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading') + const [errorMessage, setErrorMessage] = useState('') + const [watcherStatus, setWatcherStatus] = useState<WatcherStatus>({ _tag: 'idle' }) + const [lastSync, setLastSync] = useState<SyncResult | null>(null) + const [log, setLog] = useState<readonly LogEntry[]>([]) + const [port, setPort] = useState(0) + + const watcherRef = useRef<WatcherHandle | null>(null) + const openapiCacheRef = useRef(new Map<string, unknown>()) + const cancelledRef = useRef(false) + const lastFileRef = useRef<string | null>(null) + + const pushLog = useCallback((entry: LogEntry) => { + setLog((prev) => [entry, ...prev].slice(0, MAX_LOG_ENTRIES)) + }, []) + + useEffect(() => { + /** + * @private + * Wraps a state setter so it becomes a no-op after the effect cleanup runs. + */ + function guard<T>(setter: (value: T) => void): (value: T) => void { + return (value: T) => { + if (!cancelledRef.current) { + setter(value) + } + } + } + + async function init() { + const paths = createPaths(process.cwd()) + + if (props.clean) { + await clean(paths) + } + + const [configErr, config] = await loadConfig(paths.repoRoot) + if (configErr) { + guard(setErrorMessage)(configErr.message) + guard(setPhase)('error') + return + } + + const openapiCache = openapiCacheRef.current + + try { + const initialResult = await sync(config, { + paths, + quiet: props.quiet ?? true, + openapiCache, + }) + if (cancelledRef.current) { + return + } + guard(setLastSync)(initialResult) + } catch (error) { + guard(setErrorMessage)(`Initial sync failed: ${toError(error).message}`) + guard(setPhase)('error') + return + } + + try { + const { onConfigReload, port: resolvedPort } = await startDevServer({ + config, + paths, + port: props.port, + theme: props.theme, + colorMode: props.colorMode, + vscode: props.vscode, + }) + + if (cancelledRef.current) { + return + } + + guard(setPort)(resolvedPort) + + const guardedPushLog = guard(pushLog) + + const callbacks: WatcherCallbacks = { + onStatusChange: guard(setWatcherStatus), + onSyncComplete: (result) => { + guard(setLastSync)(result) + const file = lastFileRef.current + if (file) { + guardedPushLog({ + timestamp: formatTime(new Date()), + action: 'synced', + file, + elapsed: result.elapsed, + }) + } + }, + onFileChange: (filename) => { + // oxlint-disable-next-line functional/immutable-data -- ref tracking for log entries + lastFileRef.current = filename + }, + onConfigReloaded: () => { + guardedPushLog({ + timestamp: formatTime(new Date()), + action: 'restarted', + file: 'zpress.config.ts', + elapsed: 0, + }) + }, + } + + const watcher = createWatcher({ + initialConfig: config, + paths, + callbacks, + onConfigReload, + openapiCache, + }) + + // oxlint-disable-next-line functional/immutable-data -- assigning ref for teardown + watcherRef.current = watcher + guard(setPhase)('ready') + } catch (error) { + guard(setErrorMessage)(`Dev server failed: ${toError(error).message}`) + guard(setPhase)('error') + } + } + + init() + + return () => { + // oxlint-disable-next-line functional/immutable-data -- ref mutation for unmount guard + cancelledRef.current = true + if (watcherRef.current) { + watcherRef.current.close() + } + } + }, []) + + useHotkey({ + keys: ['r'], + action: () => { + if (watcherRef.current) { + watcherRef.current.resync() + } + }, + active: phase === 'ready' && isTTY, + }) + + useHotkey({ + keys: ['c'], + action: () => { + setLog([]) + }, + active: phase === 'ready' && isTTY, + }) + + useHotkey({ + keys: ['o'], + action: () => { + openBrowser(`http://localhost:${port}`) + }, + active: phase === 'ready' && isTTY, + }) + + useInput( + (input, key) => { + if (phase !== 'ready') { + return + } + + if (input === 'q' || (key.ctrl && input === 'c')) { + if (watcherRef.current) { + watcherRef.current.close() + } + exit() + } + }, + { isActive: isTTY } + ) + + const width = Math.min(columns, 80) + + if (phase === 'error') { + return ( + <Box flexDirection="column" padding={1}> + <Banner /> + <Box marginTop={1}> + <Alert variant="error" title="Dev Server Error" width={width}> + {errorMessage} + </Alert> + </Box> + </Box> + ) + } + + if (phase === 'loading') { + return ( + <Box flexDirection="column" padding={1}> + <Banner /> + <Box marginTop={1}> + <Spinner label="Starting dev server..." type="dots" /> + </Box> + </Box> + ) + } + + const watcherTag = watcherStatus._tag + + return ( + <Box flexDirection="column" padding={1}> + {/* Banner + URL */} + <Banner /> + <Box marginTop={1}> + <Text dimColor> + http://localhost:<Text color="cyan">{port}</Text> + </Text> + <Spacer /> + {match(watcherTag) + .with('idle', () => <Text color="green">● Ready</Text>) + .with('syncing', () => <Spinner label="Syncing" type="dots" />) + .with('restarting', () => <Spinner label="Restarting" type="dots" />) + .with('error', () => ( + <Text color="red"> + ● Error + </Text> + )) + .exhaustive()} + </Box> + + {/* Separator */} + <Box marginTop={1}> + <Text dimColor>{'─'.repeat(width - 2)}</Text> + </Box> + + {/* Activity log */} + <Box flexDirection="column" marginTop={0}> + {log.length === 0 && ( + <Box paddingLeft={1}> + <Text dimColor>Waiting for changes...</Text> + </Box> + )} + {log.slice(0, 12).map((entry, i) => ( + <LogLine key={`${entry.timestamp}-${entry.file}-${i}`} entry={entry} first={i === 0} /> + ))} + </Box> + + {/* Separator */} + <Box marginTop={1}> + <Text dimColor>{'─'.repeat(width - 2)}</Text> + </Box> + + {/* Stats bar */} + {lastSync !== null && ( + <Box paddingLeft={1}> + <Text dimColor> + <Text color="green">{lastSync.pagesWritten}</Text> written + {' · '} + <Text color="yellow">{lastSync.pagesSkipped}</Text> skipped + {' · '} + <Text color="red">{lastSync.pagesRemoved}</Text> removed + {' · '} + {Math.round(lastSync.elapsed)}ms + </Text> + </Box> + )} + + {/* Hotkey bar */} + {isTTY && ( + <Box marginTop={1} paddingLeft={1}> + <HotkeyHint label="r" description="resync" /> + <Text dimColor> · </Text> + <HotkeyHint label="o" description="open" /> + <Text dimColor> · </Text> + <HotkeyHint label="c" description="clear" /> + <Text dimColor> · </Text> + <HotkeyHint label="q" description="quit" /> + </Box> + )} + </Box> + ) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * Render the ASCII banner. + * + * @private + * @returns React element with the zpress ASCII art + */ +function Banner(): React.ReactElement { + return ( + <Box flexDirection="column"> + {BANNER.map((line) => ( + <Text key={line} color="cyan">{line}</Text> + ))} + </Box> + ) +} + +/** + * Render a single line in the activity log. + * + * @private + * @param props - Log entry data and whether this is the most recent entry + * @returns React element for one log line + */ +function LogLine(props: { + readonly entry: LogEntry + readonly first: boolean +}): React.ReactElement { + const { entry, first } = props + const actionColor = match(entry.action) + .with('synced', () => 'green' as const) + .with('removed', () => 'red' as const) + .with('restarted', () => 'yellow' as const) + .with('error', () => 'red' as const) + .exhaustive() + const resolvedColor = match(first) + .with(true, () => actionColor) + // oxlint-disable-next-line unicorn/no-useless-undefined -- match requires explicit return + .otherwise(() => undefined) + + return ( + <Box paddingLeft={1}> + <Text dimColor={!first}>{entry.timestamp}</Text> + <Text> </Text> + <Text color={resolvedColor} dimColor={!first}> + {entry.action.padEnd(10)} + </Text> + <Text dimColor={!first}>{entry.file}</Text> + {entry.elapsed > 0 && ( + <Text dimColor> {Math.round(entry.elapsed)}ms</Text> + )} + </Box> + ) +} + +/** + * Render a single hotkey hint (e.g. "r resync"). + * + * @private + * @param props - Label and description for the hotkey + * @returns React element with styled hotkey hint + */ +function HotkeyHint(props: { + readonly label: string + readonly description: string +}): React.ReactElement { + return ( + <Text> + <Text bold color="cyan"> + {props.label} + </Text> + <Text dimColor> {props.description}</Text> + </Text> + ) +} + +/** + * Format a Date to HH:MM:SS string. + * + * @private + * @param date - Date to format + * @returns Time string in HH:MM:SS format + */ +function formatTime(date: Date): string { + return [ + String(date.getHours()).padStart(2, '0'), + String(date.getMinutes()).padStart(2, '0'), + String(date.getSeconds()).padStart(2, '0'), + ].join(':') +} diff --git a/packages/cli/src/stories/dev.stories.tsx b/packages/cli/src/stories/dev.stories.tsx new file mode 100644 index 00000000..9396f4f0 --- /dev/null +++ b/packages/cli/src/stories/dev.stories.tsx @@ -0,0 +1,308 @@ +import { stories, withFullScreen, withLayout } from '@kidd-cli/core/stories' +import { Alert, Box, Spacer, Spinner, Text } from '@kidd-cli/core/ui' +import type React from 'react' +import { match } from 'ts-pattern' +import { z } from 'zod' + +import type { LogEntry } from '../screens/dev-screen.tsx' + +const BANNER = [ + ' ██████╗ ██████╗ ███████╗███████╗███████╗', + ' ██╔══█ ██╔══██╗██╔══██╗██╔════╝██╔════╝██╔════╝', + ' ╚═══█║ ██████╔╝██████╔╝█████╗ ███████╗███████╗', + ' █╗ ██ ██╔═══╝ ██╔══██╗██╔══╝ ╚════██║╚════██║', + ' ╚█████ ██║ ██║ ██║███████╗███████║███████║', + ' ╚════╝╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝', +] + +const SAMPLE_LOG: readonly LogEntry[] = [ + { timestamp: '14:32:18', action: 'synced', file: 'docs/guides/deploying-to-vercel.md', elapsed: 12 }, + { timestamp: '14:32:15', action: 'synced', file: 'docs/getting-started/introduction.md', elapsed: 8 }, + { timestamp: '14:32:10', action: 'removed', file: 'docs/old-page.md', elapsed: 2 }, + { timestamp: '14:31:55', action: 'synced', file: 'docs/api/reference.md', elapsed: 15 }, + { timestamp: '14:31:42', action: 'restarted', file: 'zpress.config.ts', elapsed: 0 }, + { timestamp: '14:31:30', action: 'synced', file: 'docs/concepts/content.md', elapsed: 9 }, +] + +/** + * Props for the story-only DevScreenPreview component. + * Mirrors the visual states of the real DevScreen without side effects. + */ +type DevScreenPreviewProps = Record<string, unknown> & { + readonly phase: 'loading' | 'ready' | 'error' + readonly errorMessage: string + readonly watcherTag: 'idle' | 'syncing' | 'restarting' | 'error' + readonly logEntries: number + readonly pagesWritten: number + readonly pagesSkipped: number + readonly pagesRemoved: number + readonly elapsed: number + readonly port: number + readonly width: number +} + +/** + * Pure visual preview of the DevScreen for the story viewer. + * + * @param props - DevScreen visual state props + * @returns React element rendering the dev screen preview + */ +function DevScreenPreview(props: DevScreenPreviewProps): React.ReactElement { + const { width } = props + + if (props.phase === 'error') { + return ( + <Box flexDirection="column" padding={1}> + <BannerBlock /> + <Box marginTop={1}> + <Alert variant="error" title="Dev Server Error" width={width - 2}> + {props.errorMessage} + </Alert> + </Box> + </Box> + ) + } + + if (props.phase === 'loading') { + return ( + <Box flexDirection="column" padding={1}> + <BannerBlock /> + <Box marginTop={1}> + <Spinner label="Starting dev server..." type="dots" /> + </Box> + </Box> + ) + } + + const log = SAMPLE_LOG.slice(0, props.logEntries) + + return ( + <Box flexDirection="column" padding={1}> + {/* Banner + URL */} + <BannerBlock /> + <Box marginTop={1}> + <Text dimColor> + http://localhost:<Text color="cyan">{props.port}</Text> + </Text> + <Spacer /> + {props.watcherTag === 'idle' && <Text color="green">● Ready</Text>} + {props.watcherTag === 'syncing' && <Spinner label="Syncing" type="dots" />} + {props.watcherTag === 'restarting' && <Spinner label="Restarting" type="dots" />} + {props.watcherTag === 'error' && <Text color="red">● Error</Text>} + </Box> + + {/* Separator */} + <Box marginTop={1}> + <Text dimColor>{'─'.repeat(width - 2)}</Text> + </Box> + + {/* Activity log */} + <Box flexDirection="column"> + {log.length === 0 && ( + <Box paddingLeft={1}> + <Text dimColor>Waiting for changes...</Text> + </Box> + )} + {log.map((entry, i) => ( + <LogLine key={`${entry.timestamp}-${entry.file}`} entry={entry} first={i === 0} /> + ))} + </Box> + + {/* Separator */} + <Box marginTop={1}> + <Text dimColor>{'─'.repeat(width - 2)}</Text> + </Box> + + {/* Stats bar */} + <Box paddingLeft={1}> + <Text dimColor> + <Text color="green">{props.pagesWritten}</Text> written + {' · '} + <Text color="yellow">{props.pagesSkipped}</Text> skipped + {' · '} + <Text color="red">{props.pagesRemoved}</Text> removed + {' · '} + {Math.round(props.elapsed)}ms + </Text> + </Box> + + {/* Hotkey bar */} + <Box marginTop={1} paddingLeft={1}> + <HotkeyHint label="r" description="resync" /> + <Text dimColor> · </Text> + <HotkeyHint label="o" description="open" /> + <Text dimColor> · </Text> + <HotkeyHint label="c" description="clear" /> + <Text dimColor> · </Text> + <HotkeyHint label="q" description="quit" /> + </Box> + </Box> + ) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * @private + */ +function BannerBlock(): React.ReactElement { + return ( + <Box flexDirection="column"> + {BANNER.map((line) => ( + <Text key={line} color="cyan">{line}</Text> + ))} + </Box> + ) +} + +/** + * @private + */ +function LogLine(props: { + readonly entry: LogEntry + readonly first: boolean +}): React.ReactElement { + const { entry, first } = props + const actionColors: Record<LogEntry['action'], string> = { + synced: 'green', + removed: 'red', + restarted: 'yellow', + error: 'red', + } + const actionColor = actionColors[entry.action] + const resolvedColor = match(first) + .with(true, () => actionColor) + // oxlint-disable-next-line unicorn/no-useless-undefined -- match requires explicit return + .otherwise(() => undefined) + + return ( + <Box paddingLeft={1}> + <Text dimColor={!first}>{entry.timestamp}</Text> + <Text> </Text> + <Text color={resolvedColor} dimColor={!first}> + {entry.action.padEnd(10)} + </Text> + <Text dimColor={!first}>{entry.file}</Text> + {entry.elapsed > 0 && ( + <Text dimColor> {Math.round(entry.elapsed)}ms</Text> + )} + </Box> + ) +} + +/** + * @private + */ +function HotkeyHint(props: { + readonly label: string + readonly description: string +}): React.ReactElement { + return ( + <Text> + <Text bold color="cyan"> + {props.label} + </Text> + <Text dimColor> {props.description}</Text> + </Text> + ) +} + +const schema = z.object({ + phase: z.enum(['loading', 'ready', 'error']).default('ready'), + errorMessage: z.string().default('Config file not found'), + watcherTag: z.enum(['idle', 'syncing', 'restarting', 'error']).default('idle'), + logEntries: z.number().default(6), + pagesWritten: z.number().default(3), + pagesSkipped: z.number().default(42), + pagesRemoved: z.number().default(0), + elapsed: z.number().default(187), + port: z.number().default(3000), + width: z.number().default(80), +}) + +/** + * Stories for the DevScreen TUI component. + */ +export default stories<DevScreenPreviewProps>({ + title: 'DevScreen', + component: DevScreenPreview, + schema, + defaults: { + port: 3000, + pagesWritten: 3, + pagesSkipped: 42, + pagesRemoved: 0, + elapsed: 187, + logEntries: 6, + errorMessage: 'Config file not found', + width: 80, + }, + decorators: [withLayout({ width: 80, padding: 0 })], + stories: { + Loading: { + props: { phase: 'loading' }, + description: 'Initial loading state with ASCII banner', + }, + 'Idle (No Activity)': { + props: { + phase: 'ready', + watcherTag: 'idle', + logEntries: 0, + }, + description: 'Ready state before any file changes', + }, + 'Idle (With Log)': { + props: { + phase: 'ready', + watcherTag: 'idle', + logEntries: 6, + pagesWritten: 12, + pagesSkipped: 35, + elapsed: 342, + }, + description: 'Watching with activity log populated', + }, + Syncing: { + props: { + phase: 'ready', + watcherTag: 'syncing', + logEntries: 3, + }, + description: 'File change detected, sync in progress', + }, + Restarting: { + props: { + phase: 'ready', + watcherTag: 'restarting', + logEntries: 5, + }, + description: 'Config change detected, dev server restarting', + }, + 'Watcher Error': { + props: { + phase: 'ready', + watcherTag: 'error', + logEntries: 2, + }, + description: 'Watcher encountered an error (e.g. ENOSPC)', + }, + 'Fatal Error': { + props: { phase: 'error' }, + description: 'Fatal startup error — config missing or invalid', + }, + Fullscreen: { + props: { + phase: 'ready', + watcherTag: 'idle', + logEntries: 6, + pagesWritten: 12, + pagesSkipped: 35, + elapsed: 342, + }, + decorators: [withFullScreen()], + description: 'Fullscreen mode as rendered in the real dev command', + }, + }, +}) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index b06a99dd..15bfcdc1 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,8 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": ".", - "outDir": "dist" + "outDir": "dist", + "jsx": "react-jsx" }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] } diff --git a/packages/config/schemas/schema.json b/packages/config/schemas/schema.json index 10ae455a..de657ac0 100644 --- a/packages/config/schemas/schema.json +++ b/packages/config/schemas/schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/joggrdocs/zpress/v0.5.0/packages/config/schemas/schema.json", + "$id": "https://raw.githubusercontent.com/joggrdocs/zpress/v0.5.1/packages/config/schemas/schema.json", "title": "Zpress Configuration", "description": "Configuration file for zpress documentation framework", "$ref": "#/definitions/ZpressConfig", diff --git a/packages/core/src/banner/index.ts b/packages/core/src/banner/index.ts index 92ce5b43..f3219eb5 100644 --- a/packages/core/src/banner/index.ts +++ b/packages/core/src/banner/index.ts @@ -112,30 +112,30 @@ export async function generateAssets( () => generateIconSvg(params.config), ] - const written = await generators.reduce<Promise<readonly string[]>>( - async (accPromise, generate) => { - const acc = await accPromise + const results = await Promise.all( + generators.map(async (generate) => { const [err, asset] = generate() if (err) { - return acc + return null } const filePath = path.resolve(params.publicDir, asset.filename) - const shouldWrite = await shouldGenerate(filePath) + const shouldWrite = await shouldGenerate(filePath, asset.content) if (!shouldWrite) { - return acc + return null } const [writeErr, filename] = await writeAsset({ asset, publicDir: params.publicDir }) if (writeErr) { - return acc + return null } - return [...acc, filename] - }, - Promise.resolve([]) + return filename + }) ) + const written = results.filter((f): f is string => f !== null) + return [null, written] } @@ -148,26 +148,31 @@ export async function generateAssets( * * Returns `true` when: * - The file does not exist (first generation) - * - The file exists and contains the zpress-generated marker (regeneration) + * - The file exists with the generated marker and content differs from `newContent` * * Returns `false` when: * - The file exists without the marker (user-customized) + * - The file exists with the marker but content is identical to `newContent` * * @private * @param filePath - Absolute path to the file to check + * @param newContent - The content that would be written * @returns Whether the file should be (re)generated */ -async function shouldGenerate(filePath: string): Promise<boolean> { +async function shouldGenerate(filePath: string, newContent: string): Promise<boolean> { // oxlint-disable-next-line security/detect-non-literal-fs-filename -- path is constructed from trusted publicDir + known filenames - const content = await fs.readFile(filePath, 'utf8').catch(() => null) - if (content === null) { + const existing = await fs.readFile(filePath, 'utf8').catch(() => null) + if (existing === null) { return true } - const [firstLine] = content.split('\n') - if (firstLine === GENERATED_MARKER) { - return true + const [firstLine] = existing.split('\n') + if (firstLine !== GENERATED_MARKER) { + return false + } + if (existing === newContent) { + return false } - return false + return true } interface WriteAssetParams { @@ -192,7 +197,10 @@ async function writeAsset(params: WriteAssetParams): Promise<AssetResult<string> if (writeResult) { return [ - assetError('write_failed', `Failed to write ${params.asset.filename}: ${writeResult.message}`), + assetError( + 'write_failed', + `Failed to write ${params.asset.filename}: ${writeResult.message}` + ), null, ] } diff --git a/packages/core/src/sync/copy.ts b/packages/core/src/sync/copy.ts index 707e8987..75dcc3ac 100644 --- a/packages/core/src/sync/copy.ts +++ b/packages/core/src/sync/copy.ts @@ -26,6 +26,11 @@ export async function copyPage(page: PageData, ctx: SyncContext): Promise<Manife const outPath = path.resolve(ctx.outDir, page.outputPath) await fs.mkdir(path.dirname(outPath), { recursive: true }) + const cached = await tryMtimeSkip(page, ctx) + if (cached !== null) { + return cached + } + const content: string = await (async () => { if (page.source) { const raw = await fs.readFile(page.source, 'utf8') @@ -71,12 +76,15 @@ export async function copyPage(page: PageData, ctx: SyncContext): Promise<Manife } } + const fmHash = hashFrontmatter(page.frontmatter) + if (prev && prev.contentHash === contentHash) { return { source: relativeSource, sourceMtime: await resolveSourceMtime(), contentHash, outputPath: page.outputPath, + frontmatterHash: fmHash, } } @@ -87,6 +95,7 @@ export async function copyPage(page: PageData, ctx: SyncContext): Promise<Manife sourceMtime: await resolveSourceMtime(), contentHash, outputPath: page.outputPath, + frontmatterHash: fmHash, } } @@ -94,6 +103,65 @@ export async function copyPage(page: PageData, ctx: SyncContext): Promise<Manife // Private // --------------------------------------------------------------------------- +/** + * Compute an MD5 hash of serialized frontmatter for change detection. + * + * @private + * @param fm - Frontmatter key-value pairs + * @returns Hex MD5 digest of the JSON-serialized frontmatter + */ +function hashFrontmatter(fm: Frontmatter): string { + return createHash('md5').update(JSON.stringify(fm)).digest('hex') +} + +/** + * Attempt to skip the full copy pipeline by comparing source mtime and frontmatter hash. + * + * Returns the previous manifest entry when all conditions hold: + * - `ctx.skipMtimeOptimization` is not true + * - Page has a source file (not virtual) + * - Previous manifest has a matching entry with the same `sourceMtime` + * - Page frontmatter hash matches stored `frontmatterHash` + * + * @private + * @param page - Page data to check + * @param ctx - Sync context with previous manifest + * @returns Previous manifest entry if skippable, null otherwise + */ +async function tryMtimeSkip(page: PageData, ctx: SyncContext): Promise<ManifestEntry | null> { + if (ctx.skipMtimeOptimization === true) { + return null + } + if (page.source === null || page.source === undefined) { + return null + } + if (ctx.previousManifest === null || ctx.previousManifest === undefined) { + return null + } + const prev = ctx.previousManifest.files[page.outputPath] + if (prev === null || prev === undefined) { + return null + } + if (prev.sourceMtime === null || prev.sourceMtime === undefined) { + return null + } + const stat = await fs.stat(page.source).catch(() => null) + if (stat === null) { + return null + } + if (stat.mtimeMs !== prev.sourceMtime) { + return null + } + const fmHash = hashFrontmatter(page.frontmatter) + if (prev.frontmatterHash === null || prev.frontmatterHash === undefined) { + return null + } + if (fmHash !== prev.frontmatterHash) { + return null + } + return prev +} + /** * Rewrite relative markdown links in source content when a source map is available. * No-op when the context has no source map (e.g. during resolve-only passes). diff --git a/packages/core/src/sync/images.ts b/packages/core/src/sync/images.ts index 760e2a98..486edaed 100644 --- a/packages/core/src/sync/images.ts +++ b/packages/core/src/sync/images.ts @@ -107,8 +107,16 @@ export async function rewriteImages(params: { const baseName = path.basename(imagePath, path.extname(imagePath)) const hash = createHash('md5').update(imagePath).digest('hex').slice(0, 8) const filename = `${baseName}-${hash}${ext}` + const destPath = path.resolve(imagesOutDir, filename) + + // Skip copy when destination is at least as recent as source + const destStat = await fs.stat(destPath).catch(() => null) + if (destStat && destStat.mtimeMs >= exists.mtimeMs) { + return [imagePath, `/images/${filename}`] as const + } + // oxlint-disable-next-line security/detect-non-literal-fs-filename -- paths are constructed from trusted repo root + user source paths - await fs.copyFile(absoluteImagePath, path.resolve(imagesOutDir, filename)) + await fs.copyFile(absoluteImagePath, destPath) return [imagePath, `/images/${filename}`] as const }) diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 8a3f4eb2..0fc016e2 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' @@ -18,8 +19,8 @@ import { resolveEntries } from './resolve/index.ts' import { buildSourceMap } from './rewrite-links.ts' import { generateNav } from './sidebar/index.ts' import { injectLandingPages } from './sidebar/inject.ts' -import { buildMultiSidebar } from './sidebar/multi.ts' -import type { PageData, ResolvedEntry, SyncContext } from './types.ts' +import { writeMetaFiles } from './sidebar/write-meta.ts' +import type { PageData, ResolvedEntry, SidebarItem, SyncContext } from './types.ts' import { enrichWorkspaceCards, synthesizeWorkspaceSections } from './workspace.ts' /** @@ -41,6 +42,11 @@ export interface SyncOptions { * When true, suppress all log output during sync. */ readonly quiet?: boolean + /** + * Shared cache of dereferenced OpenAPI specs. + * Persisted across sync passes in dev mode to avoid re-parsing unchanged specs. + */ + readonly openapiCache?: Map<string, unknown> } /** @@ -64,14 +70,23 @@ export async function sync(config: ZpressConfig, options: SyncOptions): Promise< // Generate banner/logo/icon SVGs (skips user-customized files automatically) const assetConfig = buildAssetConfig(config) - await generateAssets({ config: assetConfig, publicDir: options.paths.publicDir }) + const assetConfigHash = createHash('sha256').update(JSON.stringify(assetConfig)).digest('hex') + + const previousManifest = await loadManifest(outDir) + + // Skip asset generation entirely when config hasn't changed + const assetConfigChanged = + previousManifest === null || + previousManifest === undefined || + previousManifest.assetConfigHash !== assetConfigHash + if (assetConfigChanged) { + await generateAssets({ config: assetConfig, publicDir: options.paths.publicDir }) + } // Copy public assets into content/public/ so Rspress can resolve them // (Rspress looks for public/ inside the root directory, which is .zpress/content/) await copyAll(options.paths.publicDir, path.resolve(outDir, 'public')) - const previousManifest = await loadManifest(outDir) - const ctx: SyncContext = { repoRoot, outDir, @@ -79,6 +94,7 @@ export async function sync(config: ZpressConfig, options: SyncOptions): Promise< previousManifest, manifest: { files: {}, timestamp: Date.now() }, quiet, + openapiCache: options.openapiCache, } // 0. Synthesize workspace sections from apps/packages/workspaces config @@ -104,11 +120,19 @@ export async function sync(config: ZpressConfig, options: SyncOptions): Promise< // 2.1 Write workspace data (always — independent of home page strategy) const workspaceResult = buildWorkspaceData(config) - await fs.writeFile( - path.resolve(outDir, '.generated/workspaces.json'), - JSON.stringify(workspaceResult.data, null, 2), - 'utf8' - ) + const standaloneScopePaths = collectStandaloneScopePaths(resolved) + await Promise.all([ + fs.writeFile( + path.resolve(outDir, '.generated/workspaces.json'), + JSON.stringify(workspaceResult.data, null, 2), + 'utf8' + ), + fs.writeFile( + path.resolve(outDir, '.generated/scopes.json'), + JSON.stringify(standaloneScopePaths, null, 2), + 'utf8' + ), + ]) // 2.2 Auto-generate home page when no explicit index.md exists const hasExplicitHome = sectionPages.some((p) => p.outputPath === 'index.md') @@ -136,13 +160,18 @@ export async function sync(config: ZpressConfig, options: SyncOptions): Promise< // 3. Copy/generate all pages (sections + home + planning + openapi) const allPages = [...pages, ...planningPages, ...openapiResult.pages] + // Detect structural changes — skip mtime optimization when page count changes + const skipMtimeOptimization = + previousManifest !== null && + previousManifest !== undefined && + allPages.length !== previousManifest.resolvedCount + // Build source-to-output map for link rewriting const sourceMap = buildSourceMap({ pages: allPages, repoRoot }) - const copyCtx = { ...ctx, sourceMap } + const copyCtx: SyncContext = { ...ctx, sourceMap, skipMtimeOptimization } - const { written, skipped } = await allPages.reduce( - async (accPromise, page) => { - const counts = await accPromise + const pageResults = await Promise.all( + allPages.map(async (page) => { const entry = await copyPage(page, copyCtx) const prevFile = match(previousManifest) .with(P.nonNullable, (m) => m.files[entry.outputPath]) @@ -152,14 +181,25 @@ export async function sync(config: ZpressConfig, options: SyncOptions): Promise< match(prevFile) .with(P.nonNullable, (p) => p.contentHash) .otherwise(() => {}) - // oxlint-disable-next-line eslint/no-unused-expressions -- side-effect boundary: manifest is intentionally mutable context accumulated during sync - ctx.manifest.files[entry.outputPath] = entry + return { entry, isNew } + }) + ) + + // Build manifest from collected results + const manifestFiles = Object.fromEntries( + pageResults.map(({ entry }) => [entry.outputPath, entry]) + ) + // oxlint-disable-next-line eslint/no-unused-expressions -- side-effect boundary: manifest is intentionally mutable context accumulated during sync + ctx.manifest.files = manifestFiles + + const { written, skipped } = pageResults.reduce( + (counts, { isNew }) => { if (isNew) { return { written: counts.written + 1, skipped: counts.skipped } } return { written: counts.written, skipped: counts.skipped + 1 } }, - Promise.resolve({ written: 0, skipped: 0 }) + { written: 0, skipped: 0 } ) // 5. Clean stale files @@ -167,23 +207,35 @@ export async function sync(config: ZpressConfig, options: SyncOptions): Promise< .with(P.nonNullable, async (m) => await cleanStaleFiles(outDir, m, ctx.manifest)) .otherwise(() => Promise.resolve(0)) - // 6. Generate sidebar + nav - const sortedSidebar = buildMultiSidebar(resolved, openapiResult.sidebar) + // 6. Generate nav + write Rspress-native _meta.json / _nav.json const nav = generateNav(config, resolved) - - await fs.writeFile( - path.resolve(outDir, '.generated/sidebar.json'), - JSON.stringify(sortedSidebar, null, 2), - 'utf8' - ) - await fs.writeFile( - path.resolve(outDir, '.generated/nav.json'), - JSON.stringify(nav, null, 2), - 'utf8' - ) - - // 7. Save manifest - await saveManifest(outDir, ctx.manifest) + await writeMetaFiles({ + contentDir: outDir, + entries: resolved, + nav, + openapiEntries: openapiResult.sidebar, + }) + + // 6.1 Write sidebar.json + nav.json snapshots for tooling / debugging. + // Rspress no longer reads these (sidebar/nav come from _meta.json/_nav.json), + // but they provide a single-file view of the resolved structure. + await Promise.all([ + fs.writeFile( + path.resolve(outDir, '.generated/sidebar.json'), + JSON.stringify(buildSidebarSnapshot(resolved), null, 2), + 'utf8' + ), + fs.writeFile(path.resolve(outDir, '.generated/nav.json'), JSON.stringify(nav, null, 2), 'utf8'), + ]) + + // 7. Save manifest with incremental metadata + const manifest = { + ...ctx.manifest, + assetConfigHash, + openapiMtimes: openapiResult.specMtimes, + resolvedCount: allPages.length, + } + await saveManifest(outDir, manifest) // 8. Write bare-bones README in .zpress/ root await writeZpressReadme(options.paths.outputRoot) @@ -269,16 +321,17 @@ async function copyAll(src: string, dest: string): Promise<void> { } await fs.mkdir(dest, { recursive: true }) const entries = await fs.readdir(src, { withFileTypes: true }) - await entries.reduce(async (prevPromise, entry) => { - await prevPromise - const srcPath = path.resolve(src, entry.name) - const destPath = path.resolve(dest, entry.name) - if (entry.isDirectory()) { - await copyAll(srcPath, destPath) - } else { - await fs.copyFile(srcPath, destPath) - } - }, Promise.resolve()) + await Promise.all( + entries.map(async (entry) => { + const srcPath = path.resolve(src, entry.name) + const destPath = path.resolve(dest, entry.name) + if (entry.isDirectory()) { + await copyAll(srcPath, destPath) + } else { + await fs.copyFile(srcPath, destPath) + } + }) + ) } /** @@ -310,6 +363,22 @@ function concatPage(pages: readonly PageData[], page: PageData | undefined): Pag return [...pages] } +/** + * Collect standalone sidebar scope paths from resolved entries. + * + * Returns an array of link paths (e.g. `["/packages", "/contributing"]`) + * for sections that have `standalone: true`. These paths are written to + * `.generated/scopes.json` and consumed at runtime by the custom Sidebar + * component to isolate standalone sections into their own sidebar scope. + * + * @private + * @param entries - Top-level resolved entries + * @returns Array of standalone scope path strings + */ +function collectStandaloneScopePaths(entries: readonly ResolvedEntry[]): readonly string[] { + return entries.filter((e) => e.standalone && e.link).map((e) => e.link as string) +} + /** * Extract an `AssetConfig` from the zpress config. * Falls back to 'Documentation' when no title is set. @@ -321,3 +390,78 @@ function concatPage(pages: readonly PageData[], page: PageData | undefined): Pag function buildAssetConfig(config: ZpressConfig): AssetConfig { return { title: config.title ?? 'Documentation', tagline: config.tagline } } + +/** + * Build a flat sidebar snapshot from the resolved entry tree. + * + * Produces a `Record<string, SidebarItem[]>` keyed by top-level section + * link (or `"/"` for non-standalone sections). Used only for the + * `.generated/sidebar.json` snapshot — Rspress reads `_meta.json` instead. + * + * @private + * @param entries - Resolved entry tree + * @returns Sidebar record suitable for JSON serialization + */ +function buildSidebarSnapshot(entries: readonly ResolvedEntry[]): Record<string, unknown[]> { + return { + '/': entriesToSidebarItems(entries) as unknown[], + } +} + +/** + * Recursively convert resolved entries to sidebar items for the snapshot. + * + * @private + * @param items - Resolved entries to convert + * @returns Flat sidebar items array + */ +function entriesToSidebarItems(items: readonly ResolvedEntry[]): readonly SidebarItem[] { + return items.filter((e) => !e.hidden).map(entryToSidebarItem) +} + +/** + * Convert a single resolved entry to a sidebar item. + * + * @private + * @param entry - Resolved entry to convert + * @returns Sidebar item with text, optional link, and optional children + */ +function entryToSidebarItem(entry: ResolvedEntry): SidebarItem { + if (entry.items && entry.items.length > 0) { + return { + text: entry.title, + ...maybeSidebarLink(entry.link), + ...maybeSidebarCollapsed(entry.collapsible), + items: entriesToSidebarItems(entry.items), + } + } + return { text: entry.title, link: entry.link } +} + +/** + * Return a link property if defined. + * + * @private + * @param link - Optional link string + * @returns Object with link, or empty object + */ +function maybeSidebarLink(link: string | undefined): { readonly link?: string } { + if (link) { + return { link } + } + return {} +} + +/** + * Return a collapsed property if collapsible is true. + * + * @private + * @param collapsible - Optional collapsible flag + * @returns Object with collapsed flag, or empty object + */ +function maybeSidebarCollapsed(collapsible: boolean | undefined): { readonly collapsed?: true } { + if (collapsible) { + return { collapsed: true as const } + } + return {} +} diff --git a/packages/core/src/sync/openapi.ts b/packages/core/src/sync/openapi.ts index 6ac49231..a7a3bb83 100644 --- a/packages/core/src/sync/openapi.ts +++ b/packages/core/src/sync/openapi.ts @@ -6,6 +6,7 @@ * by tag, and generates one `.mdx` per operation plus an index overview page. */ +import fs from 'node:fs/promises' import path from 'node:path' import SwaggerParser from '@apidevtools/swagger-parser' @@ -33,6 +34,7 @@ export interface OpenAPISidebarEntry { export interface SyncOpenAPIResult { readonly sidebar: readonly OpenAPISidebarEntry[] readonly pages: readonly PageData[] + readonly specMtimes: Readonly<Record<string, number>> } /** @@ -50,17 +52,24 @@ export async function syncAllOpenAPI(ctx: SyncContext): Promise<SyncOpenAPIResul const allConfigs = [...rootConfigs, ...workspaceConfigs] if (allConfigs.length === 0) { - return { sidebar: [], pages: [] } + return { sidebar: [], pages: [], specMtimes: {} } } const configResults = await Promise.all(allConfigs.map((entry) => syncOpenAPI(entry.config, ctx))) + const specMtimes = configResults.reduce<Record<string, number>>( + // oxlint-disable-next-line unicorn/no-accumulating-spread -- Object.assign avoids O(n^2) copies + (acc, result) => Object.assign(acc, result.specMtimes), + {} + ) + return { sidebar: configResults.map((result, index) => ({ prefix: allConfigs[index].config.path, sidebar: result.sidebar, })), pages: configResults.flatMap((result) => result.pages), + specMtimes, } } @@ -99,6 +108,7 @@ interface TagGroup { interface SingleSyncResult { readonly sidebar: readonly SidebarItem[] readonly pages: readonly PageData[] + readonly specMtimes: Readonly<Record<string, number>> } /** @@ -120,26 +130,61 @@ interface ConfigEntry { */ async function syncOpenAPI(config: OpenAPIConfig, ctx: SyncContext): Promise<SingleSyncResult> { const specAbsPath = path.resolve(ctx.repoRoot, config.spec) - const api = await SwaggerParser.dereference(specAbsPath, { - dereference: { circular: 'ignore' }, - }).catch((error: unknown) => { - const message = match(error) - .with(P.instanceOf(Error), (e) => e.message) - .otherwise(String) - console.warn(`[zpress] Failed to parse OpenAPI spec at ${specAbsPath}: ${message}`) - return null - }) + const specRelPath = config.spec + + // Stat the spec file to get mtime for caching + const specStat = await fs.stat(specAbsPath).catch(() => null) + const specMtime = match(specStat) + .with(P.nonNullable, (s) => s.mtimeMs) + .otherwise(() => null) + + // Try to use cached dereferenced spec when mtime is unchanged + const api = await (async () => { + if (specMtime !== null && ctx.openapiCache) { + const prevMtime = match(ctx.previousManifest) + .with(P.nonNullable, (m) => (m.openapiMtimes ?? {})[specRelPath]) + .otherwise(() => null) + if (prevMtime === specMtime && ctx.openapiCache.has(specRelPath)) { + return ctx.openapiCache.get(specRelPath) as Record<string, unknown> + } + } + const parsed = await SwaggerParser.dereference(specAbsPath, { + dereference: { circular: 'ignore' }, + }).catch((error: unknown) => { + const message = match(error) + .with(P.instanceOf(Error), (e) => e.message) + .otherwise(String) + console.warn(`[zpress] Failed to parse OpenAPI spec at ${specAbsPath}: ${message}`) + return null + }) + if (parsed === null) { + // Evict stale cache entry so the next pass retries instead of serving stale output + if (ctx.openapiCache) { + ctx.openapiCache.delete(specRelPath) + } + return null + } + // Populate cache on successful parse + if (ctx.openapiCache) { + ctx.openapiCache.set(specRelPath, parsed) + } + return parsed + })() + + const specMtimes: Readonly<Record<string, number>> = match(specMtime) + .with(P.nonNullable, (mt) => ({ [specRelPath]: mt })) + .otherwise(() => ({})) // oxlint-disable-next-line security/detect-possible-timing-attacks -- not a security comparison if (api === null) { - return { sidebar: [], pages: [] } + return { sidebar: [], pages: [], specMtimes } } const paths = (api as Record<string, unknown>).paths as | Record<string, Record<string, unknown>> | undefined if (paths === null || paths === undefined) { - return { sidebar: [], pages: [] } + return { sidebar: [], pages: [], specMtimes } } const operations = extractOperations(paths) @@ -179,7 +224,7 @@ async function syncOpenAPI(config: OpenAPIConfig, ctx: SyncContext): Promise<Sin .otherwise(() => 'method-path' as const) const sidebarItems = buildSidebarItems(title, prefix, tagGroups, sidebarLayout) - return { sidebar: sidebarItems, pages } + return { sidebar: sidebarItems, pages, specMtimes } } /** diff --git a/packages/core/src/sync/sidebar/index.ts b/packages/core/src/sync/sidebar/index.ts index 3dadfc41..23ce7fa3 100644 --- a/packages/core/src/sync/sidebar/index.ts +++ b/packages/core/src/sync/sidebar/index.ts @@ -1,30 +1,6 @@ -import { log } from '@clack/prompts' - import type { NavItem, ZpressConfig } from '../../types.ts' import { isEntrySlug } from '../resolve/path.ts' -import type { ResolvedEntry, RspressNavItem, SidebarItem } from '../types.ts' - -/** - * Convert resolved entry tree to Rspress sidebar config. - * - * Leaf pages are placed before sections (directories) at every level. - * - * @param entries - Resolved entry tree from the sync engine - * @returns Rspress sidebar items - */ -export function generateSidebar(entries: readonly ResolvedEntry[]): SidebarItem[] { - const visible = entries.filter((e) => !e.hidden) - const pages = visible.filter((e) => !e.items || e.items.length === 0) - const sections = visible.filter((e) => e.items && e.items.length > 0) - - return [...pages, ...sections].flatMap((entry) => { - const item = buildSidebarEntry(entry) - if (item === undefined) { - return [] - } - return [item] - }) -} +import type { ResolvedEntry, RspressNavItem } from '../types.ts' /** * Generate Rspress nav config from resolved tree. @@ -59,35 +35,6 @@ export function generateNav( // Private // --------------------------------------------------------------------------- -/** - * Build a SidebarItem from a resolved entry. - * - * @private - * @param entry - Resolved entry to convert - * @returns Sidebar item for Rspress config - */ -function buildSidebarEntry(entry: ResolvedEntry): SidebarItem | undefined { - if (entry.items && entry.items.length > 0) { - const dedupedItems = filterDuplicateChildLink(entry.items, entry.link) - return { - text: entry.title, - items: generateSidebar(dedupedItems), - ...maybeCollapsed(entry.collapsible), - ...maybeLink(entry.link), - } - } - - if (!entry.link || entry.link.trim().length === 0) { - log.error(`[zpress] Leaf entry "${entry.title}" has no link — skipping`) - return undefined - } - - return { - text: entry.title, - link: entry.link, - } -} - /** * Build a NavItem from a resolved entry. * @@ -124,34 +71,6 @@ function findFirstLink(entry: ResolvedEntry): string | undefined { return undefined } -/** - * Return a collapsed property object if collapsible is true. - * - * @private - * @param collapsible - Whether the sidebar group is collapsible - * @returns Object with collapsed property, or empty object - */ -function maybeCollapsed(collapsible: boolean | undefined): { collapsed?: true } { - if (collapsible) { - return { collapsed: true as const } - } - return {} -} - -/** - * Return a link property object if link is defined. - * - * @private - * @param link - Optional link string - * @returns Object with link property, or empty object - */ -function maybeLink(link: string | undefined): { link?: string } { - if (link) { - return { link } - } - return {} -} - /** * Find a child entry whose link ends with a known entry-page slug. * @@ -295,25 +214,3 @@ function mapNavItem(item: NavItem): RspressNavItem { ...maybeItems(item), } } - -/** - * Remove any direct child whose link duplicates the parent section link. - * - * When a child (e.g. "Overview") has the same link as its parent group, - * it is redundant in the sidebar — the group header already navigates there. - * Keeping both causes double-highlighting of the active state. - * - * @private - * @param items - Direct child entries - * @param parentLink - Parent section link to check against - * @returns Filtered child entries with duplicates removed - */ -function filterDuplicateChildLink( - items: readonly ResolvedEntry[], - parentLink: string | undefined -): readonly ResolvedEntry[] { - if (!parentLink) { - return items - } - return items.filter((item) => item.link !== parentLink) -} diff --git a/packages/core/src/sync/sidebar/meta.ts b/packages/core/src/sync/sidebar/meta.ts new file mode 100644 index 00000000..2827af72 --- /dev/null +++ b/packages/core/src/sync/sidebar/meta.ts @@ -0,0 +1,515 @@ +/** + * _meta.json generation — converts ResolvedEntry trees to Rspress sidebar meta format. + * + * Rspress uses per-directory `_meta.json` files to control sidebar ordering, + * labels, and collapsed state. This module converts the resolved entry tree + * into a flat list of directories, each with its ordered meta items. + * + * Placement is filesystem-first: each entry is placed in the `_meta.json` + * of its actual parent directory (derived from output path or link), not + * from its position in the config tree. This handles cases where the + * config tree doesn't match the directory hierarchy. + * + * @see https://rspress.dev/guide/basic/auto-nav-sidebar + */ + +import { basename, dirname, extname } from 'node:path' + +import type { ResolvedEntry } from '../types.ts' + +/** + * A meta item for Rspress's `_meta.json` sidebar control. + * + * String items reference files by stem (Rspress reads frontmatter for label). + * Object items describe directories, files, or section headers with explicit labels. + */ +export type MetaItem = string | MetaDirItem | MetaFileItem | MetaSectionHeaderItem + +/** + * A directory entry in `_meta.json`. + */ +export interface MetaDirItem { + readonly type: 'dir' + readonly name: string + readonly label: string + readonly collapsed?: boolean +} + +/** + * A file entry in `_meta.json` with explicit label. + */ +export interface MetaFileItem { + readonly type: 'file' + readonly name: string + readonly label: string +} + +/** + * A section header entry in `_meta.json` — visual label with no link. + */ +export interface MetaSectionHeaderItem { + readonly type: 'section-header' + readonly label: string +} + +/** + * A directory that needs a `_meta.json` written. + */ +export interface MetaDirectory { + /** + * Directory path relative to content dir (e.g., "concepts" or "concepts/engine"). + */ + readonly dirPath: string + /** + * Ordered meta items for this directory's `_meta.json`. + */ + readonly items: readonly MetaItem[] +} + +/** + * Build the root `_meta.json` items for the content directory. + * + * Rspress creates a separate sidebar per top-level directory by default. + * A root `_meta.json` listing all sections as `dir` items tells Rspress + * to generate a single unified sidebar (keyed by `"/"`) instead, with + * full HMR support for sidebar changes. + * + * @param entries - Top-level resolved entries (sections) + * @returns Root meta items for the content-level `_meta.json` + */ +export function buildRootMeta(entries: readonly ResolvedEntry[]): readonly MetaItem[] { + return entries + .filter((e) => !e.hidden) + .flatMap((entry) => { + const name = resolveDirName(entry) + if (name === null) { + return [] + } + return [ + { + type: 'dir' as const, + name, + label: entry.title, + }, + ] + }) +} + +/** + * Build all `_meta.json` directory entries from a resolved entry tree. + * + * Uses a filesystem-first approach: each entry is placed in the `_meta.json` + * of its actual parent directory on disk, regardless of its position in the + * config tree. This correctly handles cases where the config tree nesting + * doesn't match the directory hierarchy (e.g., a section at + * `/contributing/concepts/engine` configured as a flat child of `/contributing`). + * + * @param entries - Top-level resolved entries (sections) + * @returns Flat array of directories needing `_meta.json` files + */ +export function buildMetaDirectories(entries: readonly ResolvedEntry[]): readonly MetaDirectory[] { + const { placements } = flattenToPlacements(entries, 0) + return groupPlacementsByDir(placements) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * A placement instruction: which directory a meta item belongs to. + * + * @private + */ +interface MetaPlacement { + /** + * Target directory path for the `_meta.json` (e.g., "contributing/concepts"). + */ + readonly dirPath: string + /** + * The meta item to write. + */ + readonly item: MetaItem + /** + * Insertion order for stable sorting within a directory. + */ + readonly order: number + /** + * Whether this is a section (directory) or a leaf (file). + * Leaves are ordered before sections within each directory. + */ + readonly isSection: boolean +} + +/** + * Accumulator returned by the recursive flattener. + * + * @private + */ +interface FlattenResult { + readonly placements: readonly MetaPlacement[] + readonly nextOrder: number +} + +/** + * Recursively flatten the entry tree into placement instructions. + * + * For each visible entry, determines its target `_meta.json` directory + * from the filesystem path and emits a placement. Sections are recursed + * into so their children also get placements. + * + * @private + * @param entries - Entries to flatten + * @param startOrder - Starting order counter for stable sorting + * @returns Placements and the next available order counter + */ +function flattenToPlacements(entries: readonly ResolvedEntry[], startOrder: number): FlattenResult { + return entries + .filter((e) => !e.hidden) + .reduce<FlattenResult>( + (acc, entry) => { + const isSection = hasChildren(entry) + + if (isSection) { + return flattenSection(entry, acc) + } + + return flattenLeaf(entry, acc) + }, + { placements: [], nextOrder: startOrder } + ) +} + +/** + * Create placements for a section entry and recurse into its children. + * + * The section itself becomes a `dir` item in its filesystem parent's + * `_meta.json`. Its children are recursively flattened. + * + * @private + * @param entry - Section entry with children + * @param acc - Current accumulator + * @returns Updated accumulator with section and child placements + */ +function flattenSection(entry: ResolvedEntry, acc: FlattenResult): FlattenResult { + const dirPath = resolveDirPath(entry) + if (dirPath === null) { + return acc + } + + const parentDir = dirname(dirPath) + // dirname returns '.' for top-level paths like "concepts" + const targetDir = resolveTargetDir(parentDir) + + // Only place the dir item if it has a real parent directory. + // Top-level sections (targetDir === '') are discovered by Rspress automatically. + const dirPlacement: readonly MetaPlacement[] = buildDirPlacement(targetDir, entry, acc.nextOrder) + + // Recurse into children + const childResult = flattenToPlacements(entry.items ?? [], acc.nextOrder + 1) + + return { + // oxlint-disable-next-line unicorn/no-accumulating-spread -- bounded by tree depth + placements: [...acc.placements, ...dirPlacement, ...childResult.placements], + nextOrder: childResult.nextOrder, + } +} + +/** + * Create a placement for a leaf entry. + * + * The leaf becomes a `file` item in its filesystem parent's `_meta.json`. + * + * @private + * @param entry - Leaf entry (no children) + * @param acc - Current accumulator + * @returns Updated accumulator with the leaf placement + */ +function flattenLeaf(entry: ResolvedEntry, acc: FlattenResult): FlattenResult { + const targetDir = resolveLeafParentDir(entry) + if (targetDir === null) { + return acc + } + + const placement: MetaPlacement = { + dirPath: targetDir, + item: leafToMetaItem(entry), + order: acc.nextOrder, + isSection: false, + } + + return { + // oxlint-disable-next-line unicorn/no-accumulating-spread -- bounded by entry count per section + placements: [...acc.placements, placement], + nextOrder: acc.nextOrder + 1, + } +} + +/** + * Resolve a parent directory path, mapping '.' to empty string for top-level. + * + * @private + * @param parentDir - Raw dirname result + * @returns Empty string for top-level, otherwise the parent path + */ +function resolveTargetDir(parentDir: string): string { + if (parentDir === '.') { + return '' + } + return parentDir +} + +/** + * Build a dir placement array for a section entry. + * + * Returns an empty array when the target directory is top-level (empty string), + * since Rspress discovers top-level sections automatically. + * + * @private + * @param targetDir - Target directory for the placement + * @param entry - Section entry to place + * @param order - Insertion order for stable sorting + * @returns Single-element placement array, or empty array for top-level + */ +function buildDirPlacement( + targetDir: string, + entry: ResolvedEntry, + order: number +): readonly MetaPlacement[] { + if (targetDir === '') { + return [] + } + return [ + { + dirPath: targetDir, + item: sectionToMetaItem(entry), + order, + isSection: true, + }, + ] +} + +/** + * Group placements by target directory and build MetaDirectory entries. + * + * Within each directory, leaves are ordered before sections (matching + * the existing sidebar convention), with stable ordering within each group. + * + * @private + * @param placements - All placement instructions + * @returns Deduplicated MetaDirectory entries + */ +function groupPlacementsByDir(placements: readonly MetaPlacement[]): readonly MetaDirectory[] { + const grouped = Map.groupBy(placements, (p) => p.dirPath) + + return [...grouped.entries()] + .filter(([dirPath]) => dirPath !== '') + .map(([dirPath, items]) => { + const leaves = items.filter((p) => !p.isSection).toSorted((a, b) => a.order - b.order) + const sections = items.filter((p) => p.isSection).toSorted((a, b) => a.order - b.order) + // Deduplicate items by name to avoid duplicate entries when the same + // directory/file appears multiple times (e.g., same-name landing pages) + const seen = new Set<string>() + const deduped = [...leaves, ...sections].filter((p) => { + const name = extractItemName(p.item) + if (name === null) { + return true + } + if (seen.has(name)) { + return false + } + seen.add(name) + return true + }) + return { dirPath, items: deduped.map((p) => p.item) } + }) +} + +/** + * Extract the identifying name from a meta item for deduplication. + * + * @private + * @param item - Meta item to extract name from + * @returns Name string, or null for non-deduplicable items + */ +function extractItemName(item: MetaItem): string | null { + if (typeof item === 'string') { + return item + } + if ('name' in item) { + return item.name + } + return null +} + +/** + * Check whether an entry has child items. + * + * @private + * @param entry - Resolved entry to check + * @returns True when the entry has a non-empty items array + */ +function hasChildren(entry: ResolvedEntry): boolean { + return entry.items !== undefined && entry.items !== null && entry.items.length > 0 +} + +/** + * Derive the content-relative directory path from an entry's link. + * + * @private + * @param entry - Resolved entry with a link + * @returns Directory path (e.g. "concepts") or null if invalid + */ +function resolveDirPath(entry: ResolvedEntry): string | null { + if (!entry.link) { + return null + } + const cleaned = stripLeadingSlash(entry.link) + if (cleaned === '' || cleaned === '/') { + return null + } + return cleaned +} + +/** + * Determine the parent directory for a leaf entry's `_meta.json` placement. + * + * Uses the output path's directory when available, falling back to the + * link's parent path. + * + * @private + * @param entry - Leaf entry + * @returns Parent directory path, or null + */ +function resolveLeafParentDir(entry: ResolvedEntry): string | null { + if (entry.page) { + const dir = dirname(entry.page.outputPath) + if (dir === '.') { + return null + } + return dir + } + if (entry.link) { + const cleaned = stripLeadingSlash(entry.link) + const dir = dirname(cleaned) + if (dir === '.') { + return null + } + return dir + } + return null +} + +/** + * Convert a leaf entry to a `_meta.json` file item. + * + * @private + * @param entry - Leaf resolved entry (no children) + * @returns Meta file item with name and label + */ +function leafToMetaItem(entry: ResolvedEntry): MetaItem { + const name = resolveFileStem(entry) + if (name === null) { + return entry.title + } + return { + type: 'file' as const, + name, + label: entry.title, + } +} + +/** + * Convert a section entry to a `_meta.json` directory item. + * + * @private + * @param entry - Section resolved entry (has children) + * @returns Meta directory item with name, label, and optional collapsed flag + */ +function sectionToMetaItem(entry: ResolvedEntry): MetaItem { + const name = resolveDirName(entry) + if (name === null) { + return entry.title + } + return { + type: 'dir' as const, + name, + label: entry.title, + ...maybeCollapsed(entry.collapsible), + } +} + +/** + * Extract the filename stem from a leaf entry's output path or link. + * + * @private + * @param entry - Leaf entry with optional page data + * @returns Filename stem (e.g. "deploying") or null + */ +function resolveFileStem(entry: ResolvedEntry): string | null { + if (entry.page) { + const ext = extname(entry.page.outputPath) + return basename(entry.page.outputPath, ext) + } + if (entry.link) { + return lastSegment(entry.link) + } + return null +} + +/** + * Extract the directory name from a section entry's link. + * + * @private + * @param entry - Section entry with link + * @returns Directory name (last path segment) or null + */ +function resolveDirName(entry: ResolvedEntry): string | null { + if (entry.link) { + return lastSegment(entry.link) + } + return null +} + +/** + * Return the last non-empty segment of a URL path. + * + * @private + * @param link - URL path (e.g. "/guides/deploying") + * @returns Last segment (e.g. "deploying") or null + */ +function lastSegment(link: string): string | null { + const segments = link.split('/').filter(Boolean) + if (segments.length === 0) { + return null + } + return segments.at(-1) ?? null +} + +/** + * Return a collapsed property object if collapsible is true. + * + * @private + * @param collapsible - Whether the sidebar group starts collapsed + * @returns Object with collapsed flag, or empty object + */ +function maybeCollapsed(collapsible: boolean | undefined): { readonly collapsed?: boolean } { + if (collapsible) { + return { collapsed: true } + } + return {} +} + +/** + * Strip the leading slash from a path. + * + * @private + * @param p - Path string + * @returns Path without leading slash + */ +function stripLeadingSlash(p: string): string { + if (p.startsWith('/')) { + return p.slice(1) + } + return p +} diff --git a/packages/core/src/sync/sidebar/multi.test.ts b/packages/core/src/sync/sidebar/multi.test.ts deleted file mode 100644 index a62800c9..00000000 --- a/packages/core/src/sync/sidebar/multi.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import type { ResolvedEntry } from '../types' -import { buildMultiSidebar } from './multi' - -describe('buildMultiSidebar()', () => { - it('should place non-standalone entries under the root "/" key', () => { - const entries: ResolvedEntry[] = [ - { - title: 'Guides', - link: '/guides', - items: [ - { - title: 'Setup', - link: '/guides/setup', - page: { outputPath: 'guides/setup.md', frontmatter: {} }, - }, - ], - }, - ] - - const result = buildMultiSidebar(entries, []) - - expect(result['/']).toBeDefined() - expect(result['/'].length).toBeGreaterThan(0) - }) - - it('should create sidebar keys for standalone entries', () => { - const entries: ResolvedEntry[] = [ - { - title: 'Apps', - link: '/apps', - standalone: true, - items: [ - { title: 'API', link: '/apps/api' }, - { title: 'Web', link: '/apps/web' }, - ], - }, - ] - - const result = buildMultiSidebar(entries, []) - - expect(result['/apps']).toBeDefined() - expect(result['/apps/']).toBeDefined() - }) - - it('should create orphaned child keys when children live outside parent prefix', () => { - const entries: ResolvedEntry[] = [ - { - title: 'Packages', - link: '/packages', - standalone: true, - items: [ - { title: 'AI', link: '/libs/ai' }, - { title: 'Database', link: '/libs/database' }, - ], - }, - ] - - const result = buildMultiSidebar(entries, []) - - // Parent keys still exist - expect(result['/packages']).toBeDefined() - expect(result['/packages/']).toBeDefined() - - // Orphaned child keys are created - expect(result['/libs/ai']).toBeDefined() - expect(result['/libs/ai/']).toBeDefined() - expect(result['/libs/database']).toBeDefined() - expect(result['/libs/database/']).toBeDefined() - }) - - it('should use the same sidebar content for orphaned keys as the parent', () => { - const entries: ResolvedEntry[] = [ - { - title: 'Packages', - link: '/packages', - standalone: true, - items: [ - { title: 'AI', link: '/libs/ai' }, - { title: 'DB', link: '/libs/db' }, - ], - }, - ] - - const result = buildMultiSidebar(entries, []) - - expect(result['/libs/ai/']).toBe(result['/packages/']) - expect(result['/libs/ai']).toBe(result['/packages/']) - expect(result['/libs/db/']).toBe(result['/packages/']) - expect(result['/libs/db']).toBe(result['/packages/']) - }) - - it('should not create orphaned keys for children that match the parent prefix', () => { - const entries: ResolvedEntry[] = [ - { - title: 'Apps', - link: '/apps', - standalone: true, - items: [ - { title: 'API', link: '/apps/api' }, - { title: 'Web', link: '/apps/web' }, - ], - }, - ] - - const result = buildMultiSidebar(entries, []) - - const keys = Object.keys(result) - // Only root, /apps, and /apps/ — no extra orphaned keys - expect(keys).not.toContain('/apps/api') - expect(keys).not.toContain('/apps/api/') - expect(keys).not.toContain('/apps/web') - expect(keys).not.toContain('/apps/web/') - }) - - it('should handle mixed children where some match and some are orphaned', () => { - const entries: ResolvedEntry[] = [ - { - title: 'Packages', - link: '/packages', - standalone: true, - items: [ - { title: 'Utils', link: '/packages/utils' }, - { title: 'AI', link: '/libs/ai' }, - ], - }, - ] - - const result = buildMultiSidebar(entries, []) - - // Parent keys exist - expect(result['/packages']).toBeDefined() - expect(result['/packages/']).toBeDefined() - - // Only orphaned child gets extra keys - expect(result['/libs/ai']).toBeDefined() - expect(result['/libs/ai/']).toBeDefined() - - // Matched child does NOT get extra keys - expect(Object.keys(result)).not.toContain('/packages/utils') - expect(Object.keys(result)).not.toContain('/packages/utils/') - }) - - it('should handle standalone entry with no children', () => { - const entries: ResolvedEntry[] = [ - { - title: 'Packages', - link: '/packages', - standalone: true, - }, - ] - - const result = buildMultiSidebar(entries, []) - - expect(result['/packages']).toBeDefined() - expect(result['/packages/']).toBeDefined() - }) - - it('should sort sidebar keys by length descending', () => { - const entries: ResolvedEntry[] = [ - { title: 'Guides', link: '/guides', items: [{ title: 'Setup', link: '/guides/setup' }] }, - { - title: 'Packages', - link: '/packages', - standalone: true, - items: [{ title: 'AI', link: '/libs/ai' }], - }, - ] - - const result = buildMultiSidebar(entries, []) - const keys = Object.keys(result) - - // Keys should be sorted by length descending - const lengths = keys.map((k) => k.length) - const sorted = lengths.toSorted((a, b) => b - a) - expect(lengths).toEqual(sorted) - }) - - it('should include the parent landing link in orphaned sidebar content', () => { - const entries: ResolvedEntry[] = [ - { - title: 'Packages', - link: '/packages', - standalone: true, - items: [{ title: 'AI', link: '/libs/ai' }], - }, - ] - - const result = buildMultiSidebar(entries, []) - const sidebar = result['/libs/ai/'] as { text: string; link: string }[] - - expect(sidebar.length).toBeGreaterThan(0) - - // First item should be the parent landing link - expect(sidebar[0]).toMatchObject({ text: 'Packages', link: '/packages' }) - }) -}) diff --git a/packages/core/src/sync/sidebar/multi.ts b/packages/core/src/sync/sidebar/multi.ts deleted file mode 100644 index 4a38d9a4..00000000 --- a/packages/core/src/sync/sidebar/multi.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { match, P } from 'ts-pattern' - -import type { OpenAPISidebarEntry } from '../openapi.ts' -import type { ResolvedEntry, SidebarItem } from '../types.ts' -import { generateSidebar } from './index.ts' - -/** - * Build a multi-sidebar record from resolved entries. - * - * Root entries go under `"/"`, each standalone section gets its own namespace - * keyed by `link`. - * Keys are sorted by string length (descending) for Rspress matching precedence. - * - * @param resolved - Fully resolved entry tree - * @param openapiEntries - OpenAPI sidebar entries keyed by prefix - * @returns Sorted sidebar record ready for JSON serialization - */ -export function buildMultiSidebar( - resolved: readonly ResolvedEntry[], - openapiEntries: readonly OpenAPISidebarEntry[] -): Record<string, unknown[]> { - const rootEntries = resolved.filter((e) => !e.standalone) - const standaloneEntries = resolved.filter((e) => e.standalone && e.link) - - const docsSidebar = generateSidebar(rootEntries) - - // Pre-compute children per standalone entry for building grouped sidebars - const childrenByLink = new Map<string, SidebarItem[]>( - standaloneEntries.map((entry) => { - const link = entry.link as string - const items = resolveEntryItems(entry.items) - return [link, generateSidebar(items)] - }) - ) - - const standaloneSidebar: Record<string, unknown[]> = Object.fromEntries( - standaloneEntries.flatMap((entry) => { - const entryLink = entry.link as string - const children = resolveChildrenByLink(childrenByLink, entryLink) - - // Discover sibling standalone sections that are children of this entry's parent - const parentLink = resolveParentLink(entryLink) - const parentEntry = match(parentLink) - .with(P.nonNullable, (pl) => standaloneEntries.find((e) => e.link === pl)) - .otherwise(() => {}) - - const isChild = parentEntry !== null && parentEntry !== undefined && parentEntry !== entry - - const landing: SidebarItem = { - text: entry.title, - link: entryLink, - } - - const sidebarItems = match(isChild) - .with(true, () => { - const pe = parentEntry as ResolvedEntry - const peLink = pe.link as string - const parentLanding: SidebarItem = { - text: pe.title, - link: peLink, - } - - const siblings = standaloneEntries.filter((sib) => { - const sibLink = sib.link as string - return sib.link !== peLink && sibLink.startsWith(`${peLink}/`) - }) - - const siblingGroups: SidebarItem[] = siblings.map((sib): SidebarItem => { - const sibLink = sib.link as string - const sibChildren = resolveChildrenByLink(childrenByLink, sibLink) - const isCurrent = sib.link === entry.link - - return buildSidebarGroup(sib.title, sibLink, sibChildren, !isCurrent) - }) - - return [parentLanding, ...siblingGroups] - }) - .otherwise(() => { - const childGroups: SidebarItem[] = match(children.length === 0) - .with(true, () => - standaloneEntries - .filter((child) => { - const childLink = child.link as string - return child.link !== entry.link && childLink.startsWith(`${entryLink}/`) - }) - .map((child): SidebarItem => { - const childLink = child.link as string - const childItems = resolveChildrenByLink(childrenByLink, childLink) - return buildSidebarGroup(child.title, childLink, childItems, true) - }) - ) - .otherwise(() => []) - - // Skip landing when a child already links to the same page - const firstChildLink = resolveFirstChildLink(children) - const items = match(firstChildLink === entryLink) - .with(true, () => [...children, ...childGroups]) - .otherwise(() => [landing, ...children, ...childGroups]) - - return items - }) - - const orphanedKeys = collectOrphanedChildLinks(entry.items, entryLink).flatMap( - (childLink) => - [ - [`${childLink}/`, sidebarItems], - [childLink, sidebarItems], - ] as const - ) - - return [[`${entryLink}/`, sidebarItems], [entryLink, sidebarItems], ...orphanedKeys] as const - }) - ) - - // Partition OpenAPI entries: workspace-scoped go into parent standalone sidebar, - // root-scoped get their own sidebar namespace - const standaloneLinks = new Set(standaloneEntries.map((e) => e.link as string)) - const workspaceOpenapi = openapiEntries.filter((entry) => - [...standaloneLinks].some((link) => entry.prefix.startsWith(`${link}/`)) - ) - const rootOpenapi = openapiEntries.filter( - (entry) => ![...standaloneLinks].some((link) => entry.prefix.startsWith(`${link}/`)) - ) - - // Inject workspace-scoped OpenAPI sidebar items into matching standalone sidebars - const mergedIsolatedSidebar = mergeOpenapiIntoStandalone(standaloneSidebar, workspaceOpenapi) - - const rootOpenapiSidebarRecord = buildOpenapiSidebarEntries(rootOpenapi) - - const sidebar: Record<string, unknown[]> = { - '/': docsSidebar, - ...mergedIsolatedSidebar, - ...rootOpenapiSidebarRecord, - } - - // Sort sidebar keys by string length (descending) - const sortedKeys = Object.keys(sidebar).toSorted((a, b) => b.length - a.length) - return Object.fromEntries(sortedKeys.map((key) => [key, sidebar[key]])) -} - -// --------------------------------------------------------------------------- -// Private -// --------------------------------------------------------------------------- - -/** - * Collect child links that do not fall under the parent link prefix. - * - * When a standalone section at `/packages` has children at `/libs/ai`, - * those children are "orphaned" — Rspress prefix matching on `/packages/` - * will never reach them. Returns only the links that need extra sidebar keys. - * - * @private - * @param items - Optional child entries of a standalone section - * @param parentLink - The standalone parent's link - * @returns Array of child links that are outside the parent prefix - */ -function collectOrphanedChildLinks( - items: readonly ResolvedEntry[] | undefined, - parentLink: string -): readonly string[] { - if (!items) { - return [] - } - const prefix = `${parentLink}/` - return items - .filter((child) => child.link !== undefined && child.link !== null) - .map((child) => child.link as string) - .filter((childLink) => !childLink.startsWith(prefix)) -} - -/** - * Unwrap optional entry items to a concrete array. - * - * @private - * @param items - Optional resolved entry items - * @returns Array of entries, or empty array if undefined - */ -function resolveEntryItems(items: readonly ResolvedEntry[] | undefined): readonly ResolvedEntry[] { - if (items) { - return [...items] - } - return [] -} - -/** - * Look up sidebar children for a link, returning empty array if not found. - * - * @private - * @param childrenByLink - Pre-computed map of link to sidebar items - * @param link - Link key to look up - * @returns Sidebar items for the link, or empty array - */ -function resolveChildrenByLink( - childrenByLink: ReadonlyMap<string, SidebarItem[]>, - link: string -): SidebarItem[] { - const got = childrenByLink.get(link) - if (got) { - return got - } - return [] -} - -/** - * Derive the parent link by removing the last path segment. - * - * @private - * @param entryLink - Link path to derive parent from - * @returns Parent link path, or null if at root - */ -function resolveParentLink(entryLink: string): string | null { - const segments = entryLink.split('/').slice(0, -1).join('/') - if (segments) { - return segments - } - return null -} - -/** - * Extract the link from the first child sidebar item, if present. - * - * @private - * @param children - Array of sidebar items - * @returns First child's link, or undefined - */ -function resolveFirstChildLink(children: readonly SidebarItem[]): string | undefined { - if (children.length > 0 && children[0].link) { - return children[0].link - } - return undefined -} - -/** - * Build a sidebar group item with optional collapsed children. - * - * @private - * @param text - Display text for the group - * @param link - Link path for the group - * @param children - Child sidebar items - * @param collapsed - Whether the group starts collapsed - * @returns Sidebar item with children if present, or leaf item - */ -function buildSidebarGroup( - text: string, - link: string, - children: readonly SidebarItem[], - collapsed: boolean -): SidebarItem { - if (children.length > 0) { - return { text, link, collapsed, items: children } - } - return { text, link } -} - -/** - * Merge workspace-scoped OpenAPI sidebar items into their parent standalone sidebars. - * - * For each OpenAPI entry whose prefix starts with an standalone sidebar key, - * appends the OpenAPI sidebar items to that standalone sidebar. Both trailing-slash - * and non-trailing-slash variants are updated. - * - * @private - * @param standaloneSidebar - Existing standalone sidebar record - * @param openapiEntries - Workspace-scoped OpenAPI entries to merge - * @returns Updated sidebar record with OpenAPI items injected - */ -function mergeOpenapiIntoStandalone( - standaloneSidebar: Record<string, unknown[]>, - openapiEntries: readonly OpenAPISidebarEntry[] -): Record<string, unknown[]> { - if (openapiEntries.length === 0) { - return standaloneSidebar - } - - // Build a list of OpenAPI → parent sidebar key pairings - const trailingSlashKeys = Object.keys(standaloneSidebar).filter((key) => key.endsWith('/')) - - const pairings = openapiEntries.map((entry) => { - // Find the longest (most specific) matching standalone sidebar key - const candidates = trailingSlashKeys.filter((key) => entry.prefix.startsWith(key)) - const matchingKey = candidates.toSorted((a, b) => b.length - a.length)[0] ?? null - return { entry, matchingKey } - }) - - // Fold pairings into a new sidebar record - return pairings.reduce<Record<string, unknown[]>>( - (acc, { entry, matchingKey }) => { - if (matchingKey === null) { - return acc - } - const baseKey = matchingKey.slice(0, -1) - return Object.assign(acc, { - [matchingKey]: [...(acc[matchingKey] ?? []), ...entry.sidebar], - [baseKey]: [...(acc[baseKey] ?? []), ...entry.sidebar], - }) - }, - { ...standaloneSidebar } - ) -} - -/** - * Build sidebar record entries from OpenAPI sidebar entries. - * Each entry gets both a trailing-slash and non-trailing-slash key - * for Rspress matching. - * - * @private - * @param entries - OpenAPI sidebar entries with prefix and sidebar items - * @returns Record of sidebar items keyed by prefix paths - */ -function buildOpenapiSidebarEntries( - entries: readonly OpenAPISidebarEntry[] -): Record<string, readonly SidebarItem[]> { - return Object.fromEntries( - entries.flatMap(({ prefix, sidebar }) => [ - [`${prefix}/`, [...sidebar]], - [prefix, [...sidebar]], - ]) - ) -} diff --git a/packages/core/src/sync/sidebar/write-meta.ts b/packages/core/src/sync/sidebar/write-meta.ts new file mode 100644 index 00000000..0c228220 --- /dev/null +++ b/packages/core/src/sync/sidebar/write-meta.ts @@ -0,0 +1,197 @@ +/** + * Write Rspress `_meta.json` and `_nav.json` files to the content directory. + * + * These files enable Rspress's native sidebar/nav auto-discovery with HMR + * support, replacing the static `.generated/sidebar.json` approach. + * + * @see https://rspress.dev/guide/basic/auto-nav-sidebar + */ + +import fs from 'node:fs/promises' +import path from 'node:path' + +import type { OpenAPISidebarEntry } from '../openapi.ts' +import type { ResolvedEntry, RspressNavItem, SidebarItem } from '../types.ts' +import type { MetaItem, MetaSectionHeaderItem } from './meta.ts' +import { buildMetaDirectories, buildRootMeta } from './meta.ts' + +/** + * Options for writing meta files. + */ +interface WriteMetaOptions { + /** + * Absolute path to the content output directory. + */ + readonly contentDir: string + /** + * Resolved entry tree from the sync engine. + */ + readonly entries: readonly ResolvedEntry[] + /** + * Generated nav items for `_nav.json`. + */ + readonly nav: readonly RspressNavItem[] + /** + * OpenAPI sidebar entries to write as `_meta.json` in their prefix directories. + */ + readonly openapiEntries: readonly OpenAPISidebarEntry[] +} + +/** + * Write `_meta.json` files for all section directories and `_nav.json` at root. + * + * Creates a `_meta.json` in each directory that has sidebar items, controlling + * the ordering and labels. Also writes `_nav.json` at the content root for + * Rspress navigation auto-discovery. + * + * OpenAPI directories receive flat `_meta.json` files with section-header + * items for tag groups. + * + * @param options - Content directory, resolved entries, nav items, and OpenAPI entries + * @returns Promise that resolves when all files are written + */ +export async function writeMetaFiles(options: WriteMetaOptions): Promise<void> { + const { contentDir, entries, nav, openapiEntries } = options + + const sectionDirectories = buildMetaDirectories(entries) + const openapiDirectories = openapiEntries.flatMap(buildOpenapiMetaDirectory) + const rootMeta = buildRootMeta(entries) + + const allDirectories = [...sectionDirectories, ...openapiDirectories] + + await Promise.all([ + // Write root _meta.json (unified sidebar for non-standalone sections) + fs.writeFile(path.resolve(contentDir, '_meta.json'), JSON.stringify(rootMeta, null, 2), 'utf8'), + // Write _meta.json for each subdirectory + ...allDirectories.map(async (dir) => { + const metaPath = path.resolve(contentDir, dir.dirPath, '_meta.json') + await fs.mkdir(path.dirname(metaPath), { recursive: true }) + await fs.writeFile(metaPath, JSON.stringify(dir.items, null, 2), 'utf8') + }), + // Write _nav.json at content root + fs.writeFile(path.resolve(contentDir, '_nav.json'), JSON.stringify(nav, null, 2), 'utf8'), + ]) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * Intermediate directory structure matching {@link import('./meta.ts').MetaDirectory}. + * + * @private + */ +interface MetaDirectory { + readonly dirPath: string + readonly items: readonly MetaItem[] +} + +/** + * Build a `_meta.json` directory entry for an OpenAPI sidebar. + * + * Flattens the nested tag-group sidebar structure into a flat list + * using `section-header` items for tag labels and `file` items for operations. + * The index page (overview) is listed first. + * + * @private + * @param entry - OpenAPI sidebar entry with prefix and sidebar items + * @returns MetaDirectory for the OpenAPI prefix directory + */ +function buildOpenapiMetaDirectory(entry: OpenAPISidebarEntry): readonly MetaDirectory[] { + const dirPath = stripLeadingSlash(entry.prefix) + if (dirPath === '') { + return [] + } + + const items = flattenOpenapiSidebar(entry.sidebar, entry.prefix) + + return [{ dirPath, items }] +} + +/** + * Flatten a nested OpenAPI sidebar into ordered `_meta.json` items. + * + * The root sidebar item (e.g. "API Reference") is represented by the + * index page. Each tag group becomes a `section-header` followed by + * its operation file items. + * + * @private + * @param sidebar - Nested sidebar items from OpenAPI sync + * @param prefix - URL prefix for the OpenAPI section + * @returns Flat array of meta items + */ +function flattenOpenapiSidebar( + sidebar: readonly SidebarItem[], + prefix: string +): readonly MetaItem[] { + // The root sidebar typically has one item: { text: "API Reference", items: [tag groups] } + // Extract the tag groups from the root item's children + const tagGroups = sidebar.flatMap((root) => root.items ?? []) + + const indexItem: MetaItem = { type: 'file', name: 'index', label: 'Overview' } + + const tagItems = tagGroupsToMetaItems(tagGroups, prefix) + + return [indexItem, ...tagItems] +} + +/** + * Convert OpenAPI tag groups to flat meta items with section headers. + * + * Each tag group produces a section-header followed by file items + * for each operation in that group. + * + * @private + * @param groups - Tag group sidebar items + * @param prefix - URL prefix for extracting file stems + * @returns Flat array of meta items + */ +function tagGroupsToMetaItems(groups: readonly SidebarItem[], prefix: string): readonly MetaItem[] { + return groups.reduce<MetaItem[]>((acc, group) => { + const header: MetaSectionHeaderItem = { type: 'section-header', label: group.text } + const operations: readonly MetaItem[] = (group.items ?? []).flatMap((op) => { + const stem = extractStemFromLink(op.link, prefix) + if (stem === null) { + return [] + } + return [{ type: 'file' as const, name: stem, label: op.text }] + }) + // oxlint-disable-next-line unicorn/no-accumulating-spread -- small bounded arrays (tag count) + return [...acc, header, ...operations] + }, []) +} + +/** + * Extract a filename stem from a sidebar link, removing the prefix. + * + * @private + * @param link - Sidebar link (e.g. "/api/list-users") + * @param prefix - URL prefix to strip (e.g. "/api") + * @returns Filename stem (e.g. "list-users") or null + */ +function extractStemFromLink(link: string | undefined, prefix: string): string | null { + if (!link) { + return null + } + const cleanPrefix = stripLeadingSlash(prefix) + const cleanLink = stripLeadingSlash(link) + if (cleanLink.startsWith(`${cleanPrefix}/`)) { + return cleanLink.slice(cleanPrefix.length + 1) + } + return cleanLink.split('/').at(-1) ?? null +} + +/** + * Strip the leading slash from a path. + * + * @private + * @param p - Path string + * @returns Path without leading slash + */ +function stripLeadingSlash(p: string): string { + if (p.startsWith('/')) { + return p.slice(1) + } + return p +} diff --git a/packages/core/src/sync/types.ts b/packages/core/src/sync/types.ts index 2602cbeb..bdc21545 100644 --- a/packages/core/src/sync/types.ts +++ b/packages/core/src/sync/types.ts @@ -37,6 +37,15 @@ export interface SyncContext { * Used by the copy step to rewrite relative markdown links. */ readonly sourceMap?: SourceMap + /** + * Cache of dereferenced OpenAPI specs keyed by repo-relative spec path. + * Shared across sync passes to avoid re-parsing unchanged specs. + */ + readonly openapiCache?: Map<string, unknown> + /** + * When true, bypass mtime-based skip optimization (e.g. after structural changes). + */ + readonly skipMtimeOptimization?: boolean } /** @@ -55,6 +64,21 @@ export interface Manifest { * Timestamp of last sync. */ readonly timestamp: number + /** + * SHA-256 hash of the asset config used to generate banner/logo/icon SVGs. + * When unchanged, asset generation is skipped entirely. + */ + readonly assetConfigHash?: string + /** + * Mtime (ms) of each OpenAPI spec file at last sync, keyed by repo-relative path. + * Used to skip re-parsing unchanged specs. + */ + readonly openapiMtimes?: Readonly<Record<string, number>> + /** + * Number of resolved pages in the last sync pass. + * A mismatch triggers a full resync (structural change detected). + */ + readonly resolvedCount?: number } /** @@ -77,6 +101,12 @@ export interface ManifestEntry { * Output path relative to .content/. */ readonly outputPath: string + /** + * MD5 hex of the page's injected frontmatter. + * Used with sourceMtime to detect config-driven frontmatter changes + * without reading the source file. + */ + readonly frontmatterHash?: string } /** diff --git a/packages/ui/src/config.ts b/packages/ui/src/config.ts index 66e9e9da..5783c85d 100644 --- a/packages/ui/src/config.ts +++ b/packages/ui/src/config.ts @@ -53,17 +53,16 @@ const LOADER_DOTS_JS = readJs('js/loader-dots.js') export function createRspressConfig(options: CreateRspressConfigOptions): UserConfig { const { config, paths, logLevel, vscode } = options - const sidebar = loadGenerated({ - contentDir: paths.contentDir, - name: 'sidebar.json', - fallback: {}, - }) - const nav = loadGenerated({ contentDir: paths.contentDir, name: 'nav.json', fallback: [] }) const workspaces = loadGenerated({ contentDir: paths.contentDir, name: 'workspaces.json', fallback: [], }) + const standaloneScopePaths = loadGenerated<readonly string[]>({ + contentDir: paths.contentDir, + name: 'scopes.json', + fallback: [], + }) const gitBranch = detectGitBranch() const themeName = resolveThemeName(config, options.themeOverride) @@ -155,13 +154,11 @@ export function createRspressConfig(options: CreateRspressConfigOptions): UserCo }, themeConfig: { - sidebar, - nav, darkMode: colorMode === 'toggle', search: true, // Custom zpress data injected alongside standard Rspress themeConfig. // Accessed at runtime via useSite().site.themeConfig cast to unknown. - ...({ workspaces } as Record<string, unknown>), + ...({ workspaces, standaloneScopePaths } as Record<string, unknown>), ...({ socialLinks: config.socialLinks, sidebarAbove: resolveSidebarLinks({ config, position: 'above' }), diff --git a/packages/ui/src/theme/components/sidebar/sidebar-scope.tsx b/packages/ui/src/theme/components/sidebar/sidebar-scope.tsx new file mode 100644 index 00000000..59dc0486 --- /dev/null +++ b/packages/ui/src/theme/components/sidebar/sidebar-scope.tsx @@ -0,0 +1,207 @@ +/** + * Custom Sidebar component with multi-scope support. + * + * Rspress's `_meta.json` auto-discovery generates a single unified sidebar + * keyed by `"/"`. This component filters the unified sidebar at runtime to + * isolate standalone sections (e.g. Packages, Contributing) into their own + * scope while merging the remaining sections into a shared scope. + * + * HMR works because `_meta.json` changes trigger Rspress's auto-discovery + * re-run via `addDependency()` — the sidebar data updates reactively. + */ + +import type { SidebarData } from '@rspress/core' +import { useActiveMatcher, usePage, useSidebar } from '@rspress/core/runtime' +import { SidebarList } from '@rspress/core/theme-original' +import type React from 'react' +import { useLayoutEffect, useMemo, useState } from 'react' + +import { useZpress } from '../../hooks/use-zpress' + +/** + * Union element of `SidebarData` — sidebar items may be groups, leaf items, + * dividers, or section headers. + * + * @private + */ +type SidebarDataItem = SidebarData[number] + +/** + * Scoped Sidebar that supports standalone section isolation. + * + * When the current path matches a standalone scope (e.g. `/packages`), + * only that section's sidebar items are shown. For all other paths, + * standalone sections are hidden and the remaining sections form a + * unified sidebar. + * + * @returns React element rendering the filtered sidebar + */ +export function Sidebar(): React.ReactElement { + const rawSidebarData = useSidebar() + const activeMatcher = useActiveMatcher() + const { page } = usePage() + const { standaloneScopePaths } = useZpress() + + const pathname = page.pagePath + const scopes = standaloneScopePaths ?? [] + + const filteredData = useMemo( + () => filterByScope(rawSidebarData, pathname, scopes), + [rawSidebarData, pathname, scopes] + ) + + const [sidebarData, setSidebarData] = useState<SidebarData>(() => + initializeCollapsed(structuredClone(filteredData), activeMatcher) + ) + + useLayoutEffect(() => { + setSidebarData(initializeCollapsed(filteredData, activeMatcher)) + }, [activeMatcher, filteredData]) + + return <SidebarList sidebarData={sidebarData} setSidebarData={setSidebarData} /> +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * Filter sidebar items based on the current path and standalone scopes. + * + * @private + * @param items - Full unified sidebar items + * @param pathname - Current decoded pathname + * @param scopes - Standalone scope paths + * @returns Filtered sidebar items for the active scope + */ +function filterByScope( + items: SidebarData, + pathname: string, + scopes: readonly string[] +): SidebarData { + if (scopes.length === 0) { + return [...items] + } + + const standaloneMatch = scopes.find( + (scope) => pathname === scope || pathname.startsWith(`${scope}/`) + ) + + const all = [...items] + + if (standaloneMatch) { + return all.filter((item) => isItemInScope(item, standaloneMatch)) + } + + return all.filter((item) => !scopes.some((scope) => isItemInScope(item, scope))) +} + +/** + * Check whether a sidebar item belongs to a given scope path. + * + * Matches when the item's link equals the scope or starts with scope + "/". + * Dividers and section headers (no link) are never matched. + * + * @private + * @param item - Sidebar item to check + * @param scope - Scope path (e.g. "/packages") + * @returns True when the item belongs to the scope + */ +function isItemInScope(item: SidebarDataItem, scope: string): boolean { + if (!Object.hasOwn(item, 'link')) { + return false + } + const { link } = item as { readonly link?: string } + if (!link) { + return false + } + return link === scope || link.startsWith(`${scope}/`) +} + +/** + * Walk the sidebar tree and uncollapse groups that contain the active path. + * + * Mirrors Rspress's `createInitialSidebar` logic: groups whose descendants + * match the active route get `collapsed = false` so the active page is + * visible on initial render. + * + * @private + * @param items - Sidebar items (will be mutated for collapsed state) + * @param activeMatcher - Function that checks if a link matches the current route + * @returns The same items array with collapsed state applied + */ +function initializeCollapsed( + items: SidebarData, + activeMatcher: (link: string) => boolean +): SidebarData { + const cache = new WeakMap<SidebarDataItem, boolean>() + const flat = items.filter(Boolean).flat() + flat.reduce<null>((_, item) => { + expandItem(item, activeMatcher, cache) + return null + }, null) + return flat +} + +/** + * Check whether a sidebar item or any of its descendants match the active route. + * + * @private + * @param item - Sidebar item to check + * @param activeMatcher - Route matcher function + * @param cache - Memoization cache for match results + * @returns True when the item or a descendant matches + */ +function isItemActive( + item: SidebarDataItem, + activeMatcher: (link: string) => boolean, + cache: WeakMap<SidebarDataItem, boolean> +): boolean { + const cached = cache.get(item) + if (cached !== undefined) { + return cached + } + if (Object.hasOwn(item, 'link')) { + const { link } = item as { readonly link?: string } + if (link && activeMatcher(link)) { + cache.set(item, true) + return true + } + } + if (Object.hasOwn(item, 'items')) { + const { items } = item as { readonly items: readonly SidebarDataItem[] } + const childMatch = items.some((child) => isItemActive(child, activeMatcher, cache)) + if (childMatch) { + cache.set(item, true) + return true + } + } + cache.set(item, false) + return false +} + +/** + * Recursively expand a sidebar item if it contains the active route. + * + * @private + * @param item - Sidebar item to process + * @param activeMatcher - Route matcher function + * @param cache - Memoization cache for match results + */ +function expandItem( + item: SidebarDataItem, + activeMatcher: (link: string) => boolean, + cache: WeakMap<SidebarDataItem, boolean> +): void { + if (Object.hasOwn(item, 'items')) { + const group = item as { readonly items: readonly SidebarDataItem[]; collapsed?: boolean } + group.items.reduce<null>((_, child) => { + expandItem(child, activeMatcher, cache) + return null + }, null) + if (isItemActive(item, activeMatcher, cache)) { + // oxlint-disable-next-line eslint/no-param-reassign -- intentional mutation matching Rspress's createInitialSidebar + group.collapsed = false + } + } +} diff --git a/packages/ui/src/theme/hooks/use-zpress.ts b/packages/ui/src/theme/hooks/use-zpress.ts index 62ff41bf..dd725673 100644 --- a/packages/ui/src/theme/hooks/use-zpress.ts +++ b/packages/ui/src/theme/hooks/use-zpress.ts @@ -38,6 +38,7 @@ interface ZpressThemeConfig { readonly sidebarAbove: readonly ZpressSidebarLink[] | undefined readonly sidebarBelow: readonly ZpressSidebarLink[] | undefined readonly workspaces: readonly WorkspaceGroupData[] | undefined + readonly standaloneScopePaths: readonly string[] | undefined readonly home: HomeConfig | undefined readonly zpressFooter: FooterConfig | undefined } diff --git a/packages/ui/src/theme/index.tsx b/packages/ui/src/theme/index.tsx index 1bfc0cf3..94ebee25 100644 --- a/packages/ui/src/theme/index.tsx +++ b/packages/ui/src/theme/index.tsx @@ -81,3 +81,6 @@ export type { OpenAPIOverviewProps } from './components/openapi' // Home page overrides — shadow the wildcard re-exports from theme-original export { HomeFeature } from './components/home/feature' export { HomeLayout } from './components/home/layout' + +// Sidebar override — multi-scope filtering for standalone sections +export { Sidebar } from './components/sidebar/sidebar-scope' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d6a4882..0a566619 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,8 +145,8 @@ importers: packages/cli: dependencies: '@kidd-cli/core': - specifier: ^0.13.0 - version: 0.13.0(chokidar@5.0.0)(jiti@2.6.1)(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) + specifier: ^0.22.1 + version: 0.22.1(chokidar@5.0.0)(ink@6.8.0(@types/react@19.2.14)(react-devtools-core@7.0.1)(react@19.2.4))(jiti@2.6.1)(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@8.0.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) '@rspress/core': specifier: 'catalog:' version: 2.0.6(@types/mdast@4.0.4)(@types/react@19.2.14)(core-js@3.47.0)(micromark-util-types@2.0.2)(micromark@4.0.2) @@ -165,6 +165,12 @@ importers: get-port: specifier: ^7.2.0 version: 7.2.0 + ink: + specifier: ^6.8.0 + version: 6.8.0(@types/react@19.2.14)(react-devtools-core@7.0.1)(react@19.2.4) + react: + specifier: ^19.2.0 + version: 19.2.4 ts-pattern: specifier: 'catalog:' version: 5.9.0 @@ -172,9 +178,12 @@ importers: specifier: 'catalog:' version: 4.3.6 devDependencies: - '@rslib/core': - specifier: 'catalog:' - version: 0.20.0(@microsoft/api-extractor@7.57.7(@types/node@25.5.0))(@typescript/native-preview@7.0.0-dev.20260323.1)(core-js@3.47.0)(typescript@6.0.2) + '@kidd-cli/cli': + specifier: ^0.11.2 + version: 0.11.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/react@19.2.14)(@typescript/native-preview@7.0.0-dev.20260323.1)(chokidar@5.0.0)(dotenv@17.4.0)(jiti@2.6.1)(react-devtools-core@7.0.1)(typescript@6.0.2)(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@8.0.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) + '@types/react': + specifier: ^19.1.0 + version: 19.2.14 typescript: specifier: 'catalog:' version: 6.0.2 @@ -189,7 +198,7 @@ importers: version: link:../theme c12: specifier: 4.0.0-beta.4 - version: 4.0.0-beta.4(chokidar@5.0.0)(dotenv@17.3.1)(jiti@2.6.1) + version: 4.0.0-beta.4(chokidar@5.0.0)(dotenv@17.4.0)(jiti@2.6.1) es-toolkit: specifier: 'catalog:' version: 1.45.1 @@ -442,6 +451,10 @@ importers: packages: + '@alcalzone/ansi-tokenize@0.2.5': + resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} + engines: {node: '>=18'} + '@apidevtools/json-schema-ref-parser@14.0.1': resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} engines: {node: '>= 16'} @@ -574,14 +587,35 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0-rc.3': + resolution: {integrity: sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@babel/helper-string-parser@8.0.0-rc.3': + resolution: {integrity: sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==} + engines: {node: ^20.19.0 || >=22.12.0} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.0-rc.3': + resolution: {integrity: sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@babel/parser@8.0.0-rc.3': + resolution: {integrity: sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@babel/types@8.0.0-rc.3': + resolution: {integrity: sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==} + engines: {node: ^20.19.0 || >=22.12.0} + '@braintree/sanitize-url@6.0.4': resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} @@ -643,9 +677,15 @@ packages: '@clack/core@1.1.0': resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} + '@clack/core@1.2.0': + resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} + '@clack/prompts@1.1.0': resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} + '@clack/prompts@1.2.0': + resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} + '@clerc/core@1.3.1': resolution: {integrity: sha512-8jowdURow2tXga2gUa4E7/j8/9tl5TLXAvtpbnsY3JK4PTcaKB3lyc6YrcfUTJfV06whBzBpPvr3jGcNja3Wtg==} @@ -1137,24 +1177,42 @@ packages: resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@kidd-cli/config@0.1.6': - resolution: {integrity: sha512-GQUmr9xda00NlR/IH+ABk8LKfyVnlpY+3OklP3FyePfGYB8ubsl2RD/JxscbODoL1Bp/9QwheGQ1no4mtcRlpw==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@kidd-cli/bundler@0.7.1': + resolution: {integrity: sha512-oHDfFB8ETCpuq0l5yody6RkpV50bey3daaoLhatVtWbjyLjoX7mRofgDaMBmz68xZ+4CscvH8lG9eGxUFvyaGg==} + engines: {node: '>=24'} + + '@kidd-cli/cli@0.11.2': + resolution: {integrity: sha512-sZNoy1sy1fHK3oUIvKimKQ1Lgx7X0h8AWdt3O6bCVM32S5X9Ol4Az4pR5If+MAe/WY5o0MZUxsqw2F5iNtU1FA==} + engines: {bun: '>=1.3', node: '>=24'} + hasBin: true + + '@kidd-cli/config@0.3.1': + resolution: {integrity: sha512-SCoOE6pQbWJLeEz9J3RpoNig0068+Bc70i1YQauGQBDAFMOGRDAt3jc03l5yK91uQ/7m8dI5j40PUh7hgfXEAw==} + engines: {bun: '>=1.3', node: '>=24'} - '@kidd-cli/core@0.13.0': - resolution: {integrity: sha512-qRotjabpzs5oNHYJzD1cizWF6JVQDhH05exQOAVcNv7ettsBcvx0IbIXAhHwFgcAupQ8wlytvasMCbL8U8f+Rw==} + '@kidd-cli/core@0.22.1': + resolution: {integrity: sha512-0/7guzy+0jsXQx1eXuwTf+NQZKcgIC/FdANuXQuQ5Tvp+qLyB0nZMaGhNCD4J851RW1mRRQspId85L34iKWbLQ==} + engines: {bun: '>=1.3', node: '>=24'} peerDependencies: - '@inkjs/ui': '>=2.0.0' ink: '>=5.0.0' jiti: '>=2.0.0' pino: '>=9.0.0' react: '>=18.0.0' vitest: '>=2.0.0' peerDependenciesMeta: - '@inkjs/ui': - optional: true ink: optional: true jiti: @@ -1166,8 +1224,9 @@ packages: vitest: optional: true - '@kidd-cli/utils@0.1.5': - resolution: {integrity: sha512-s5lMdcz7sFcis7v6bHy1G9It3PRleRfnVh7RcCBtmzh02d73fB0s//0U2+0H4FsWxPfmkZdskCmVVOXTCn4kTg==} + '@kidd-cli/utils@0.4.1': + resolution: {integrity: sha512-f9hBEYg2CdPNP8+yoebdcY6yUZf6+N/bMUiRKJh8fwI6d017Vy9jNeEeW3kLTaUMobtPVBC5XdbiMUxYG/b8mg==} + engines: {bun: '>=1.3', node: '>=24'} '@laufen/engine@1.2.1': resolution: {integrity: sha512-t6tAiRzMzwe0tAb9FOsj4SKw+Y3p78lmzmWV6BJpKBqIZru7f1uquNGYIdFXCoZyO/51ptZBJZGDMyagWX7+qw==} @@ -1204,8 +1263,11 @@ packages: '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} - '@napi-rs/wasm-runtime@1.1.1': - resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@napi-rs/wasm-runtime@1.1.2': + resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 '@next/env@16.1.6': resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} @@ -1285,6 +1347,9 @@ packages: '@oxc-project/types@0.115.0': resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + '@oxfmt/binding-android-arm-eabi@0.41.0': resolution: {integrity: sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1532,6 +1597,9 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@react-aria/autocomplete@3.0.0-rc.6': resolution: {integrity: sha512-uymUNJ8NW+dX7lmgkHE+SklAbxwktycAJcI5lBBw6KPZyc0EdMHC+/Fc5CUz3enIAhNwd2oxxogcSHknquMzQA==} peerDependencies: @@ -2118,36 +2186,73 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-android-arm64@1.0.0-rc.9': resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': resolution: {integrity: sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.9': resolution: {integrity: sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': resolution: {integrity: sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': resolution: {integrity: sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2155,6 +2260,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2162,6 +2274,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2169,6 +2288,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2176,6 +2302,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2183,6 +2316,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2190,29 +2330,55 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': resolution: {integrity: sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==} engines: {node: '>=14.0.0'} cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': resolution: {integrity: sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': resolution: {integrity: sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + '@rolldown/pluginutils@1.0.0-rc.9': resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==} @@ -2546,6 +2712,9 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2839,6 +3008,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2861,6 +3034,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-kit@3.0.0-beta.1: + resolution: {integrity: sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==} + engines: {node: '>=20.19.0'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -2872,6 +3049,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + azure-devops-node-api@12.5.0: resolution: {integrity: sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==} @@ -2888,8 +3069,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.10: - resolution: {integrity: sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==} + baseline-browser-mapping@2.10.13: + resolution: {integrity: sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==} engines: {node: '>=6.0.0'} hasBin: true @@ -2905,6 +3086,9 @@ packages: resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} engines: {node: '>=4'} + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -2924,6 +3108,10 @@ packages: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2976,8 +3164,8 @@ packages: call-me-maybe@1.0.2: resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} - caniuse-lite@1.0.30001781: - resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + caniuse-lite@1.0.30001782: + resolution: {integrity: sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -3030,6 +3218,18 @@ packages: clerc@1.3.1: resolution: {integrity: sha512-FUgCFbvK40HPIvR5ty5Y1V8UkesgR+h64H6ybkc8ZOCWvO9Z5F1t3Fdw+DfX6n1kDlYg640Qp3HmqVOXtKtZPQ==} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -3048,6 +3248,10 @@ packages: code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -3097,6 +3301,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -3380,6 +3588,19 @@ packages: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} + dotenv@17.4.0: + resolution: {integrity: sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==} + engines: {node: '>=12'} + + dts-resolver@2.1.3: + resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} + engines: {node: '>=20.19.0'} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3403,6 +3624,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -3469,6 +3694,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -3599,15 +3828,24 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@1.2.1: + resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + fast-string-width@1.1.0: + resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==} + fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.1.6: + resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==} + fast-xml-builder@1.1.4: resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} @@ -3630,6 +3868,10 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3885,10 +4127,18 @@ packages: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} + import-without-cache@0.2.5: + resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} + engines: {node: '>=20.19.0'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + index-to-position@1.2.0: resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} engines: {node: '>=18'} @@ -3899,6 +4149,19 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ink@6.8.0: + resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} + engines: {node: '>=20'} + peerDependencies: + '@types/react': '>=19.0.0' + react: '>=19.0.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -3950,6 +4213,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -3963,6 +4230,11 @@ packages: eslint: '*' typescript: '>=4.7.4' + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -3980,6 +4252,10 @@ packages: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -4021,6 +4297,11 @@ packages: resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} engines: {node: '>=20.0.0'} + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -4179,6 +4460,11 @@ packages: engines: {node: '>=16'} hasBin: true + liquidjs@10.25.2: + resolution: {integrity: sha512-ZbgcjEjGNlAIjqhuMzymO3lCpHgmVMftKfrq4/YLLxmKaFFeQMXRGrJTqKX7OXX1hKVPUDpTIrvL7lxt3X/hmw==} + engines: {node: '>=16'} + hasBin: true + lite-emit@4.0.0: resolution: {integrity: sha512-8krVeIZLS7JbkXz4R9xYziqHcxga6UgmomVWb45g21aB4M8qzDwr7FTEW3PJa80PTUASXeqbfipveKCB5YZdug==} @@ -4542,6 +4828,10 @@ packages: engines: {node: '>=4'} hasBin: true + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -4554,6 +4844,10 @@ packages: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -4650,6 +4944,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + oniguruma-parser@0.12.1: resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} @@ -4751,6 +5049,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -4798,6 +5100,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -4856,6 +5162,9 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4881,6 +5190,9 @@ packages: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-devtools-core@7.0.1: + resolution: {integrity: sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw==} + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -5054,6 +5366,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -5066,6 +5382,30 @@ packages: robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown-plugin-dts@0.23.2: + resolution: {integrity: sha512-PbSqLawLgZBGcOGT3yqWBGn4cX+wh2nt5FuBGdcMHyOhoukmjbhYAl8NT9sE4U38Cm9tqLOIQeOrvzeayM0DLQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@ts-macro/tsc': ^0.3.6 + '@typescript/native-preview': '>=7.0.0-dev.20260325.1' + rolldown: ^1.0.0-rc.12 + typescript: ^5.0.0 || ^6.0.0 + vue-tsc: ~3.2.0 + peerDependenciesMeta: + '@ts-macro/tsc': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rolldown@1.0.0-rc.9: resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5181,6 +5521,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + shiki@4.0.2: resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} engines: {node: '>=20'} @@ -5204,6 +5548,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -5229,6 +5576,10 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5265,6 +5616,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -5290,6 +5645,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -5391,6 +5750,10 @@ packages: resolution: {integrity: sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==} engines: {node: '>=18'} + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -5440,6 +5803,10 @@ packages: toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -5467,6 +5834,34 @@ packages: ts-pattern@5.9.0: resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} + tsdown@0.21.7: + resolution: {integrity: sha512-ukKIxKQzngkWvOYJAyptudclkm4VQqbjq+9HF5K5qDO8GJsYtMh8gIRwicbnZEnvFPr6mquFwYAVZ8JKt3rY2g==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.21.7 + '@tsdown/exe': 0.21.7 + '@vitejs/devtools': '*' + publint: ^0.3.0 + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -5514,6 +5909,9 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + underscore@1.13.8: resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} @@ -5579,6 +5977,16 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unrun@0.2.34: + resolution: {integrity: sha512-LyaghRBR++r7svhDK6tnDz2XaYHWdneBOA0jbS8wnRsHerI9MFljX4fIiTgbbNbEVzZ0C9P1OjWLLe1OqoaaEw==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -5737,6 +6145,10 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5748,6 +6160,30 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -5790,6 +6226,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -5803,6 +6242,11 @@ packages: snapshots: + '@alcalzone/ansi-tokenize@0.2.5': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@apidevtools/json-schema-ref-parser@14.0.1': dependencies: '@types/json-schema': 7.0.15 @@ -5956,10 +6400,32 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/generator@8.0.0-rc.3': + dependencies: + '@babel/parser': 8.0.0-rc.3 + '@babel/types': 8.0.0-rc.3 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + + '@babel/helper-string-parser@8.0.0-rc.3': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@8.0.0-rc.3': {} + + '@babel/parser@8.0.0-rc.3': + dependencies: + '@babel/types': 8.0.0-rc.3 + '@babel/runtime@7.29.2': {} + '@babel/types@8.0.0-rc.3': + dependencies: + '@babel/helper-string-parser': 8.0.0-rc.3 + '@babel/helper-validator-identifier': 8.0.0-rc.3 + '@braintree/sanitize-url@6.0.4': {} '@changesets/apply-release-plan@7.1.0': @@ -6109,11 +6575,23 @@ snapshots: dependencies: sisteransi: 1.0.5 + '@clack/core@1.2.0': + dependencies: + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + '@clack/prompts@1.1.0': dependencies: '@clack/core': 1.1.0 sisteransi: 1.0.5 + '@clack/prompts@1.2.0': + dependencies: + '@clack/core': 1.2.0 + fast-string-width: 1.1.0 + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + '@clerc/core@1.3.1': dependencies: '@clerc/parser': 1.3.1 @@ -6276,7 +6754,7 @@ snapshots: dependencies: '@eslint/object-schema': 3.0.3 debug: 4.4.3 - minimatch: 10.2.4 + minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -6498,12 +6976,94 @@ snapshots: '@isaacs/cliui@9.0.0': {} + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} - '@kidd-cli/config@0.1.6(chokidar@5.0.0)(dotenv@17.3.1)(jiti@2.6.1)': + '@jridgewell/trace-mapping@0.3.31': dependencies: - '@kidd-cli/utils': 0.1.5 - c12: 4.0.0-beta.4(chokidar@5.0.0)(dotenv@17.3.1)(jiti@2.6.1) + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@kidd-cli/bundler@0.7.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@typescript/native-preview@7.0.0-dev.20260323.1)(chokidar@5.0.0)(dotenv@17.4.0)(jiti@2.6.1)(typescript@6.0.2)': + dependencies: + '@kidd-cli/config': 0.3.1(chokidar@5.0.0)(dotenv@17.4.0)(jiti@2.6.1) + '@kidd-cli/utils': 0.4.1 + es-toolkit: 1.45.1 + ts-pattern: 5.9.0 + tsdown: 0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@typescript/native-preview@7.0.0-dev.20260323.1)(typescript@6.0.2) + zod: 4.3.6 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@emnapi/core' + - '@emnapi/runtime' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@typescript/native-preview' + - '@vitejs/devtools' + - chokidar + - dotenv + - giget + - jiti + - magicast + - oxc-resolver + - publint + - synckit + - typescript + - unplugin-unused + - vue-tsc + + '@kidd-cli/cli@0.11.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/react@19.2.14)(@typescript/native-preview@7.0.0-dev.20260323.1)(chokidar@5.0.0)(dotenv@17.4.0)(jiti@2.6.1)(react-devtools-core@7.0.1)(typescript@6.0.2)(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@8.0.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))': + dependencies: + '@kidd-cli/bundler': 0.7.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@typescript/native-preview@7.0.0-dev.20260323.1)(chokidar@5.0.0)(dotenv@17.4.0)(jiti@2.6.1)(typescript@6.0.2) + '@kidd-cli/config': 0.3.1(chokidar@5.0.0)(dotenv@17.4.0)(jiti@2.6.1) + '@kidd-cli/core': 0.22.1(chokidar@5.0.0)(ink@6.8.0(@types/react@19.2.14)(react-devtools-core@7.0.1)(react@19.2.4))(jiti@2.6.1)(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@8.0.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) + '@kidd-cli/utils': 0.4.1 + fs-extra: 11.3.4 + ink: 6.8.0(@types/react@19.2.14)(react-devtools-core@7.0.1)(react@19.2.4) + liquidjs: 10.25.2 + picocolors: 1.1.1 + react: 19.2.4 + tsdown: 0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@typescript/native-preview@7.0.0-dev.20260323.1)(typescript@6.0.2) + yaml: 2.8.3 + zod: 4.3.6 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@emnapi/core' + - '@emnapi/runtime' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/react' + - '@typescript/native-preview' + - '@vitejs/devtools' + - bufferutil + - chokidar + - dotenv + - giget + - jiti + - magicast + - oxc-resolver + - pino + - publint + - react-devtools-core + - synckit + - typescript + - unplugin-unused + - utf-8-validate + - vitest + - vue-tsc + + '@kidd-cli/config@0.3.1(chokidar@5.0.0)(dotenv@17.4.0)(jiti@2.6.1)': + dependencies: + '@kidd-cli/utils': 0.4.1 + c12: 4.0.0-beta.4(chokidar@5.0.0)(dotenv@17.4.0)(jiti@2.6.1) es-toolkit: 1.45.1 zod: 4.3.6 transitivePeerDependencies: @@ -6513,23 +7073,24 @@ snapshots: - jiti - magicast - '@kidd-cli/core@0.13.0(chokidar@5.0.0)(jiti@2.6.1)(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))': + '@kidd-cli/core@0.22.1(chokidar@5.0.0)(ink@6.8.0(@types/react@19.2.14)(react-devtools-core@7.0.1)(react@19.2.4))(jiti@2.6.1)(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@8.0.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: - '@clack/prompts': 1.1.0 - '@kidd-cli/config': 0.1.6(chokidar@5.0.0)(dotenv@17.3.1)(jiti@2.6.1) - '@kidd-cli/utils': 0.1.5 + '@clack/prompts': 1.2.0 + '@kidd-cli/config': 0.3.1(chokidar@5.0.0)(dotenv@17.4.0)(jiti@2.6.1) + '@kidd-cli/utils': 0.4.1 '@pinojs/redact': 0.4.0 - c12: 4.0.0-beta.4(chokidar@5.0.0)(dotenv@17.3.1)(jiti@2.6.1) - dotenv: 17.3.1 + c12: 4.0.0-beta.4(chokidar@5.0.0)(dotenv@17.4.0)(jiti@2.6.1) + dotenv: 17.4.0 es-toolkit: 1.45.1 - jsonc-parser: 3.3.1 - liquidjs: 10.25.1 + figures: 6.1.0 + liquidjs: 10.25.2 picocolors: 1.1.1 ts-pattern: 5.9.0 yaml: 2.8.3 yargs: 18.0.0 zod: 4.3.6 optionalDependencies: + ink: 6.8.0(@types/react@19.2.14)(react-devtools-core@7.0.1)(react@19.2.4) jiti: 2.6.1 react: 19.2.4 vitest: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -6538,7 +7099,7 @@ snapshots: - giget - magicast - '@kidd-cli/utils@0.1.5': + '@kidd-cli/utils@0.4.1': dependencies: es-toolkit: 1.45.1 ts-pattern: 5.9.0 @@ -6547,7 +7108,7 @@ snapshots: '@laufen/engine@1.2.1': dependencies: - '@clack/prompts': 1.1.0 + '@clack/prompts': 1.2.0 es-toolkit: 1.45.1 esbuild: 0.27.4 picocolors: 1.1.1 @@ -6649,7 +7210,7 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@napi-rs/wasm-runtime@1.1.1': + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: '@emnapi/core': 1.9.1 '@emnapi/runtime': 1.9.1 @@ -6701,6 +7262,8 @@ snapshots: '@oxc-project/types@0.115.0': {} + '@oxc-project/types@0.122.0': {} + '@oxfmt/binding-android-arm-eabi@0.41.0': optional: true @@ -6817,6 +7380,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + '@react-aria/autocomplete@3.0.0-rc.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@react-aria/combobox': 3.15.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -7862,53 +8429,108 @@ snapshots: '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 + '@rolldown/binding-android-arm64@1.0.0-rc.12': + optional: true + '@rolldown/binding-android-arm64@1.0.0-rc.9': optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.9': optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': optional: true + '@rolldown/pluginutils@1.0.0-rc.12': {} + '@rolldown/pluginutils@1.0.0-rc.9': {} '@rsbuild/core@2.0.0-beta.9(core-js@3.47.0)': @@ -8335,6 +8957,8 @@ snapshots: '@types/js-yaml@4.0.9': {} + '@types/jsesc@2.5.1': {} + '@types/json-schema@7.0.15': {} '@types/katex@0.16.8': {} @@ -8506,7 +9130,7 @@ snapshots: estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@4.1.1': dependencies: @@ -8653,6 +9277,8 @@ snapshots: ansi-styles@6.2.3: {} + ansis@4.2.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -8670,12 +9296,20 @@ snapshots: assertion-error@2.0.1: {} + ast-kit@3.0.0-beta.1: + dependencies: + '@babel/parser': 8.0.0-rc.3 + estree-walker: 3.0.3 + pathe: 2.0.3 + astral-regex@2.0.0: {} astring@1.9.0: {} asynckit@0.4.0: {} + auto-bind@5.0.1: {} + azure-devops-node-api@12.5.0: dependencies: tunnel: 0.0.6 @@ -8690,7 +9324,7 @@ snapshots: base64-js@1.5.1: optional: true - baseline-browser-mapping@2.10.10: {} + baseline-browser-mapping@2.10.13: {} better-path-resolve@1.0.0: dependencies: @@ -8702,6 +9336,8 @@ snapshots: dependencies: editions: 6.22.0 + birpc@4.0.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -8724,6 +9360,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -8755,6 +9395,19 @@ snapshots: dotenv: 17.3.1 jiti: 2.6.1 + c12@4.0.0-beta.4(chokidar@5.0.0)(dotenv@17.4.0)(jiti@2.6.1): + dependencies: + confbox: 0.2.4 + defu: 6.1.4 + exsolve: 1.0.8 + pathe: 2.0.3 + pkg-types: 2.3.0 + rc9: 3.0.0 + optionalDependencies: + chokidar: 5.0.0 + dotenv: 17.4.0 + jiti: 2.6.1 + cac@7.0.0: {} call-bind-apply-helpers@1.0.2: @@ -8769,7 +9422,7 @@ snapshots: call-me-maybe@1.0.2: {} - caniuse-lite@1.0.30001781: {} + caniuse-lite@1.0.30001782: {} ccount@2.0.1: {} @@ -8845,6 +9498,17 @@ snapshots: '@clerc/plugin-update-notifier': 1.3.1(@clerc/core@1.3.1) '@clerc/plugin-version': 1.3.1(@clerc/core@1.3.1) + cli-boxes@3.0.0: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 + client-only@0.0.1: {} cliui@9.0.1: @@ -8859,6 +9523,10 @@ snapshots: code-block-writer@13.0.3: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + collapse-white-space@2.1.0: {} color-convert@2.0.1: @@ -8891,6 +9559,8 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + cookie@1.1.1: {} copy-to-clipboard@3.3.3: @@ -9186,6 +9856,10 @@ snapshots: dotenv@17.3.1: {} + dotenv@17.4.0: {} + + dts-resolver@2.1.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -9208,6 +9882,8 @@ snapshots: emoji-regex@8.0.0: {} + empathic@2.0.0: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -9299,6 +9975,8 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -9381,7 +10059,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.4 + minimatch: 10.2.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -9471,14 +10149,24 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@1.2.1: {} + fast-string-truncated-width@3.0.3: {} + fast-string-width@1.1.0: + dependencies: + fast-string-truncated-width: 1.2.1 + fast-string-width@3.0.2: dependencies: fast-string-truncated-width: 3.0.3 fast-uri@3.1.0: {} + fast-wrap-ansi@0.1.6: + dependencies: + fast-string-width: 1.1.0 + fast-xml-builder@1.1.4: dependencies: path-expression-matcher: 1.2.0 @@ -9501,6 +10189,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -9875,8 +10567,12 @@ snapshots: import-lazy@4.0.0: {} + import-without-cache@0.2.5: {} + imurmurhash@0.1.4: {} + indent-string@5.0.0: {} + index-to-position@1.2.0: {} inherits@2.0.4: @@ -9885,6 +10581,41 @@ snapshots: ini@1.3.8: optional: true + ink@6.8.0(@types/react@19.2.14)(react-devtools-core@7.0.1)(react@19.2.4): + dependencies: + '@alcalzone/ansi-tokenize': 0.2.5 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 5.2.0 + code-excerpt: 4.0.0 + es-toolkit: 1.45.1 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.4 + react-reconciler: 0.33.0(react@19.2.4) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 8.0.0 + stack-utils: 2.0.6 + string-width: 8.2.0 + terminal-size: 4.0.1 + type-fest: 5.5.0 + widest-line: 6.0.0 + wrap-ansi: 9.0.2 + ws: 8.20.0 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.14 + react-devtools-core: 7.0.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + inline-style-parser@0.2.7: {} internmap@1.0.1: {} @@ -9925,6 +10656,10 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -9941,6 +10676,8 @@ snapshots: transitivePeerDependencies: - supports-color + is-in-ci@2.0.0: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -9953,6 +10690,8 @@ snapshots: dependencies: better-path-resolve: 1.0.0 + is-unicode-supported@2.1.0: {} + is-windows@1.0.2: {} is-wsl@3.1.1: @@ -9988,6 +10727,8 @@ snapshots: jsdoc-type-pratt-parser@7.1.1: {} + jsesc@3.1.0: {} + json-buffer@3.0.1: {} json-pointer@0.6.2: @@ -10060,7 +10801,7 @@ snapshots: laufen@1.2.1: dependencies: - '@clack/prompts': 1.1.0 + '@clack/prompts': 1.2.0 '@laufen/engine': 1.2.1 c12: 4.0.0-beta.4(chokidar@5.0.0)(dotenv@17.3.1)(jiti@2.6.1) chokidar: 5.0.0 @@ -10143,6 +10884,10 @@ snapshots: dependencies: commander: 10.0.1 + liquidjs@10.25.2: + dependencies: + commander: 10.0.1 + lite-emit@4.0.0: {} locate-path@5.0.0: @@ -10883,6 +11628,8 @@ snapshots: mime@1.6.0: {} + mimic-fn@2.1.0: {} + mimic-response@3.1.0: optional: true @@ -10894,6 +11641,10 @@ snapshots: dependencies: brace-expansion: 5.0.4 + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.12 @@ -10923,8 +11674,8 @@ snapshots: dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.10 - caniuse-lite: 1.0.30001781 + baseline-browser-mapping: 2.10.13 + caniuse-lite: 1.0.30001782 postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -10984,6 +11735,10 @@ snapshots: wrappy: 1.0.2 optional: true + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + oniguruma-parser@0.12.1: {} oniguruma-to-es@4.3.5: @@ -11135,6 +11890,8 @@ snapshots: dependencies: entities: 6.0.1 + patch-console@2.0.0: {} + path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -11164,6 +11921,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + pify@4.0.1: {} pkg-types@2.3.0: @@ -11226,6 +11985,8 @@ snapshots: quansync@0.2.11: {} + quansync@1.0.0: {} + queue-microtask@1.2.3: {} rc-config-loader@4.1.4: @@ -11331,6 +12092,15 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + react-devtools-core@7.0.1: + dependencies: + shell-quote: 1.8.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -11609,6 +12379,11 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + reusify@1.1.0: {} rimraf@6.1.3: @@ -11618,7 +12393,50 @@ snapshots: robust-predicates@3.0.3: {} - rolldown@1.0.0-rc.9: + rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260323.1)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(typescript@6.0.2): + dependencies: + '@babel/generator': 8.0.0-rc.3 + '@babel/helper-validator-identifier': 8.0.0-rc.3 + '@babel/parser': 8.0.0-rc.3 + '@babel/types': 8.0.0-rc.3 + ast-kit: 3.0.0-beta.1 + birpc: 4.0.0 + dts-resolver: 2.1.3 + get-tsconfig: 4.13.7 + obug: 2.1.1 + picomatch: 4.0.4 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + optionalDependencies: + '@typescript/native-preview': 7.0.0-dev.20260323.1 + typescript: 6.0.2 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + rolldown@1.0.0-rc.9(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): dependencies: '@oxc-project/types': 0.115.0 '@rolldown/pluginutils': 1.0.0-rc.9 @@ -11635,9 +12453,12 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.9 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.9 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.9 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.9 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.9(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' rsbuild-plugin-dts@0.20.0(@microsoft/api-extractor@7.57.7(@types/node@25.5.0))(@rsbuild/core@2.0.0-beta.9(core-js@3.47.0))(@typescript/native-preview@7.0.0-dev.20260323.1)(typescript@6.0.2): dependencies: @@ -11794,6 +12615,9 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.8.3: + optional: true + shiki@4.0.2: dependencies: '@shikijs/core': 4.0.2 @@ -11835,6 +12659,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} simple-concat@1.0.1: @@ -11859,6 +12685,11 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + source-map-js@1.2.1: {} source-map@0.6.1: {} @@ -11893,6 +12724,10 @@ snapshots: sprintf-js@1.0.3: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} stackframe@1.3.4: {} @@ -11919,6 +12754,11 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -12018,6 +12858,8 @@ snapshots: ansi-escapes: 7.3.0 supports-hyperlinks: 3.2.0 + terminal-size@4.0.1: {} + text-table@0.2.0: {} textextensions@6.11.0: @@ -12054,6 +12896,8 @@ snapshots: toggle-selection@1.0.6: {} + tree-kill@1.2.2: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -12076,6 +12920,35 @@ snapshots: ts-pattern@5.9.0: {} + tsdown@0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@typescript/native-preview@7.0.0-dev.20260323.1)(typescript@6.0.2): + dependencies: + ansis: 4.2.0 + cac: 7.0.0 + defu: 6.1.4 + empathic: 2.0.0 + hookable: 6.1.0 + import-without-cache: 0.2.5 + obug: 2.1.1 + picomatch: 4.0.4 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260323.1)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(typescript@6.0.2) + semver: 7.7.4 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + unrun: 0.2.34(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + optionalDependencies: + typescript: 6.0.2 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - synckit + - vue-tsc + tslib@2.8.1: {} tsx@4.21.0: @@ -12123,6 +12996,11 @@ snapshots: uc.micro@2.1.0: {} + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + underscore@1.13.8: {} undici-types@7.18.2: {} @@ -12202,6 +13080,13 @@ snapshots: universalify@2.0.1: {} + unrun@0.2.34(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + dependencies: + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -12271,13 +13156,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + vite@8.0.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@oxc-project/runtime': 0.115.0 lightningcss: 1.32.0 - picomatch: 4.0.3 + picomatch: 4.0.4 postcss: 8.5.8 - rolldown: 1.0.0-rc.9 + rolldown: 1.0.0-rc.9(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) tinyglobby: 0.2.15 optionalDependencies: '@types/node': 25.5.0 @@ -12286,6 +13171,9 @@ snapshots: jiti: 2.6.1 tsx: 4.21.0 yaml: 2.8.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: @@ -12307,7 +13195,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -12334,6 +13222,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@6.0.0: + dependencies: + string-width: 8.2.0 + word-wrap@1.2.5: {} wrap-ansi@9.0.2: @@ -12345,6 +13237,11 @@ snapshots: wrappy@1.0.2: optional: true + ws@7.5.10: + optional: true + + ws@8.20.0: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 @@ -12384,6 +13281,8 @@ snapshots: yocto-queue@0.1.0: {} + yoga-layout@3.2.1: {} + zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/zpress.config.ts b/zpress.config.ts index 032fc6e8..84e1c715 100644 --- a/zpress.config.ts +++ b/zpress.config.ts @@ -584,6 +584,18 @@ export default defineConfig({ include: 'contributing/concepts/*.md', sort: 'alpha', }, + { + title: { from: 'heading' }, + path: '/contributing/concepts/engine', + include: 'contributing/concepts/engine/*.md', + sort: 'alpha', + }, + { + title: { from: 'heading' }, + path: '/contributing/references', + include: 'contributing/references/*.md', + sort: 'alpha', + }, { title: { from: 'heading' }, path: '/contributing/guides',