fix, feat, doc: massive build system refactorion, bug fixes and apex documentation (#19)
This commit is contained in:
1 parent
e8b42c60c2
commit
91f5245823
165 files changed
+8598
-1205
No files matched your search
@@ -0,0 +1,12 @@
|
||||
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
|
||||
#/ public-key encryption for .env files /
|
||||
#/ [how it works](https://dotenvx.com/encryption) /
|
||||
#/----------------------------------------------------------/
|
||||
DOTENV_PUBLIC_KEY="02a9d0f6522086b04ac7763e008a6ad0273c58258715e613c977fd6c82a2030d92"
|
||||
|
||||
# .env
|
||||
COM_ERMNVLDMR_STATIC_S3_BUCKET="encrypted:BL1vJLB9uPSvsUvrJah4UegRU8uPdFjE7sya+Fae3rwOmVzWsK5+TP4z4YzigTAuwit4FANvANvhRV46se+ZgprYplU8Cw1tZ5mHKO4rWAjif2sJCnsGXUKp8GZ2J7PrMH35MY0YrXX4NVqe18b2NEv2B9K7"
|
||||
COM_ERMNVLDMR_STATIC_S3_REGION="encrypted:BAj3iuo7tWWNR2V4yQ+LHnFCB4JcZp7TTfUtvv+2FPntNhwc/fV95IeFWz6S0z6aPRkDG+UQzPXNwdMz7y8ZbTHbduDDrgydHu1fST1NfQd2s1Bbpf+yJTG7dPBSc5RSNKZkkpB4v55qg/UC"
|
||||
COM_ERMNVLDMR_STATIC_S3_ENDPOINT_URL="encrypted:BNkQrgsrxtz544vuJbgwWUh5cPjHG5wbmHNcxTUWrzbFJJsdncaAitviCvIrS7Q41w+FiWkssv3D05Tu8Inn2P62E6G3PU4gNxO2oLdmGEdrzUqB/xvxOvLvos42j54QOZf7lOqMPKH6SL2/rheCAo10gSCicCqkfh4xsCAbYpyL"
|
||||
COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID="encrypted:BCsp8p4bp3cB+9grxHo634isRUaFGU3MOc1lr8ClSFRYLjqjqukyrbJCTZWV2Xdo8iQ33EylmbzUltbwzTWMQcjJSvIskesVLyV93q60o/Mu9L9jg/apwHuBgHv4zVuHstJ0zkHWgapbNTIlWD3RCm0ujeX/H1gBH3U="
|
||||
COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY="encrypted:BM7aPliOyiPrqFPZMfqpn534Nu+Y0Nbh/YRoxv2KY6XAi2dNO0LSEf97DqSUQYdsnu9iibZmVNktF2K/D6mtHr9+gumF6KsmaLH65KE7c9ly7NdFQHL9I26wxRq1Sr3Zgrfs954J6oURSlBRKqJFdcTsBltuuupGuqJHyLS9+DE0jD+bl0Qg0lA="
|
||||
@@ -0,0 +1,145 @@
|
||||
name: packages/ssg - next/required
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'packages/ssg/**'
|
||||
- '.github/workflows/packages-ssg-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ci-ssg-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
|
||||
- name: Setup Node.js with pnpm cache
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache pnpm store
|
||||
id: cache-pnpm-store
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
build-configs:
|
||||
name: Build - Configs
|
||||
needs: setup-stage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-ssg:
|
||||
name: Lint - SSG
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Lint SSG package
|
||||
run: pnpm --filter @ermnvldmr/ssg lint
|
||||
|
||||
typecheck-ssg:
|
||||
name: Typecheck - SSG
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Typecheck SSG package
|
||||
run: pnpm --filter @ermnvldmr/ssg typecheck
|
||||
|
||||
build-ssg:
|
||||
name: Build - SSG
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Build SSG package
|
||||
run: pnpm --filter @ermnvldmr/ssg build
|
||||
@@ -0,0 +1,145 @@
|
||||
name: packages/ssg - pr/required
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'packages/ssg/**'
|
||||
- '.github/workflows/packages-ssg-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-ssg-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
|
||||
- name: Setup Node.js with pnpm cache
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache pnpm store
|
||||
id: cache-pnpm-store
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
build-configs:
|
||||
name: Build - Configs
|
||||
needs: setup-stage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-ssg:
|
||||
name: Lint - SSG
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Lint SSG package
|
||||
run: pnpm --filter @ermnvldmr/ssg lint
|
||||
|
||||
typecheck-ssg:
|
||||
name: Typecheck - SSG
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Typecheck SSG package
|
||||
run: pnpm --filter @ermnvldmr/ssg typecheck
|
||||
|
||||
build-ssg:
|
||||
name: Build - SSG
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Build SSG package
|
||||
run: pnpm --filter @ermnvldmr/ssg build
|
||||
@@ -12,6 +12,10 @@ concurrency:
|
||||
group: ci-stl-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
|
||||
@@ -12,6 +12,10 @@ concurrency:
|
||||
group: ci-pr-stl-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
|
||||
@@ -14,6 +14,10 @@ concurrency:
|
||||
group: ci-ui-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
|
||||
@@ -14,6 +14,10 @@ concurrency:
|
||||
group: ci-pr-ui-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
|
||||
@@ -10,6 +10,10 @@ concurrency:
|
||||
group: ci-docs-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-hugo:
|
||||
name: Setup - Hugo
|
||||
|
||||
@@ -11,6 +11,10 @@ concurrency:
|
||||
group: ci-pr-docs-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-hugo:
|
||||
name: Setup - Hugo
|
||||
|
||||
@@ -15,6 +15,10 @@ concurrency:
|
||||
group: ci-static-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
|
||||
@@ -15,6 +15,10 @@ concurrency:
|
||||
group: ci-pr-static-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
|
||||
@@ -3,6 +3,10 @@ name: services/www - next/checkout-release-branch
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
workflow-create-release-branch:
|
||||
name: Workflow - Create Release Branch
|
||||
|
||||
@@ -15,6 +15,10 @@ concurrency:
|
||||
group: ci-www-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
|
||||
@@ -15,6 +15,10 @@ concurrency:
|
||||
group: ci-pr-www-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
|
||||
@@ -3,6 +3,10 @@ name: services/www - releases/dispatch-release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
|
||||
@@ -9,6 +9,10 @@ concurrency:
|
||||
group: ci-www-rel-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
|
||||
+5
-2
@@ -2,6 +2,9 @@
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# service static assets (local only, synced with S3)
|
||||
**/public/static/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -18,9 +21,9 @@ storybook-static/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# environment variables
|
||||
# environment variables (.env.dev is encrypted and tracked; .env is plaintext)
|
||||
.env
|
||||
.env.production
|
||||
.env.keys
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STATIC_DIR="$SCRIPT_DIR/../../services/www/public/static"
|
||||
|
||||
if [ ! -d "$STATIC_DIR" ]; then
|
||||
echo "Directory $STATIC_DIR does not exist. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Optimizing images in $STATIC_DIR using cwebp..."
|
||||
|
||||
# Find all png, jpg, jpeg files
|
||||
find "$STATIC_DIR" -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" \) | while read -r file; do
|
||||
# Determine output path (replace extension with .webp)
|
||||
dir=$(dirname "$file")
|
||||
base=$(basename "$file")
|
||||
ext="${base##*.}"
|
||||
name="${base%.*}"
|
||||
output="$dir/$name.webp"
|
||||
|
||||
# Check if webp already exists and is newer
|
||||
if [ -f "$output" ] && [ "$output" -nt "$file" ]; then
|
||||
echo "Skipping $file (already optimized)"
|
||||
else
|
||||
echo "Optimizing: $file -> $output"
|
||||
cwebp -q 80 "$file" -o "$output"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Image optimization complete!"
|
||||
@@ -0,0 +1,105 @@
|
||||
# @ermnvldmr/cli
|
||||
|
||||
An extensible, configuration-driven CLI toolkit designed specifically for the ermnvldmr monorepo.
|
||||
|
||||
## Overview
|
||||
|
||||
The `@ermnvldmr/cli` package provides a unified way to declare, manage, and execute scripts across different packages and services in the monorepo. Instead of cluttering `package.json` with complex bash scripts or maintaining ad-hoc Node.js script files, developers can define their package's commands in a strongly-typed `.config/cli.ts` file.
|
||||
|
||||
Under the hood, it uses [Commander.js](https://github.com/tj/commander.js) for argument parsing and [JITI](https://github.com/unjs/jiti) for seamless, dynamic TypeScript execution.
|
||||
|
||||
## Features
|
||||
|
||||
- **Centralized Configuration:** Define all commands in a single `.config/cli.ts` file per package.
|
||||
- **Type-Safe Builders:** Use `bashCommand` and `nodeCommand` builders to get strict TypeScript inference.
|
||||
- **Bash & Node Support:** Easily execute shell scripts (via `execa`) or arbitrary asynchronous Node.js functions.
|
||||
- **Commander Integration:** Automatically registers commands, descriptions, and arguments into a robust CLI interface.
|
||||
|
||||
## Installation
|
||||
|
||||
This package is intended to be used as a development dependency within the monorepo's workspace packages.
|
||||
|
||||
```bash
|
||||
pnpm add -D @ermnvldmr/cli --workspace
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Create a Configuration File
|
||||
|
||||
Create a `.config/cli.ts` file in the root of your package/service (e.g., `packages/ui/.config/cli.ts`).
|
||||
|
||||
```typescript
|
||||
import { defineCli, bashCommand, nodeCommand } from '@ermnvldmr/cli';
|
||||
|
||||
export default defineCli({
|
||||
commands: [
|
||||
bashCommand({
|
||||
name: 'hello',
|
||||
description: 'Prints a hello message via bash',
|
||||
script: 'echo "Hello from Bash!"'
|
||||
}),
|
||||
|
||||
nodeCommand({
|
||||
name: 'build:custom',
|
||||
description: 'Runs a custom node build step',
|
||||
options: [
|
||||
{
|
||||
flags: '--minify',
|
||||
description: 'Enable minification',
|
||||
defaultValue: false
|
||||
}
|
||||
],
|
||||
action: async (options) => {
|
||||
console.log('Building with node...');
|
||||
if (options.minify) {
|
||||
console.log('Minification enabled!');
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Run the CLI
|
||||
|
||||
You can invoke the CLI directly using `pnpm exec` from the package directory:
|
||||
|
||||
```bash
|
||||
pnpm exec ermnvldmr-cli hello
|
||||
pnpm exec ermnvldmr-cli build:custom --minify
|
||||
```
|
||||
|
||||
Alternatively, you can wire it into your `package.json` scripts:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"cli": "ermnvldmr-cli",
|
||||
"hello": "ermnvldmr-cli hello"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `defineCli(config: CliConfig)`
|
||||
|
||||
An identity helper function that provides strict TypeScript type-inference for your configuration object. It returns the configuration unmodified.
|
||||
|
||||
### `bashCommand(config: Omit<BashCommandConfig, 'type'>)`
|
||||
|
||||
A builder function for defining shell commands.
|
||||
- **`script`**: The exact bash string to execute. It will be executed using `execa` with `shell: true` and `stdio: 'inherit'`.
|
||||
|
||||
### `nodeCommand(config: Omit<NodeCommandConfig, 'type'>)`
|
||||
|
||||
A builder function for defining arbitrary JavaScript/TypeScript actions.
|
||||
- **`action`**: An asynchronous function `(options: Record<string, unknown>) => Promise<void> | void` that executes when the command is invoked.
|
||||
|
||||
## Development
|
||||
|
||||
This package adheres to the global monorepo standards outlined in `GEMINI.md`:
|
||||
- **Strict TypeScript**: No `any` types are permitted.
|
||||
- **TSDoc**: All exported members must include comprehensive TSDoc comments.
|
||||
- **TDD**: Modifications and new builders should be accompanied by Vitest unit tests.
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@ermnvldmr/cli",
|
||||
"version": "1.0.0",
|
||||
"description": "Extensible CLI tool for ermnvldmr projects",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ermnvldmr-cli": "./dist/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@dotenvx/dotenvx": "^1.75.1",
|
||||
"commander": "^13.1.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-glob": "^3.3.3",
|
||||
"jiti": "^2.4.2",
|
||||
"mime-types": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ermnvldmr/eslint-config": "workspace:*",
|
||||
"@ermnvldmr/rsbuild-config": "workspace:*",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/node": "^22.13.0",
|
||||
"sharp": "^0.35.2",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runCli } from './lib/runner.js';
|
||||
|
||||
runCli().catch((err) => {
|
||||
console.error('An unexpected error occurred:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { defineCli, bashCommand, nodeCommand } from './index.js';
|
||||
|
||||
describe('public exports', () => {
|
||||
it('defineCli returns the passed config unmodified', () => {
|
||||
const config = defineCli({
|
||||
commands: [],
|
||||
});
|
||||
expect(config.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it('exports builders', () => {
|
||||
expect(typeof bashCommand).toBe('function');
|
||||
expect(typeof nodeCommand).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CliConfig } from './lib/types.js';
|
||||
|
||||
/**
|
||||
* Helper function to define the CLI configuration with strict type-safety.
|
||||
*
|
||||
* @param config - The CLI configuration containing the array of commands.
|
||||
* @returns The unmodified CLI configuration.
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = defineCli({
|
||||
* commands: [
|
||||
* bashCommand({ name: 'hello', description: 'say hello', script: 'echo hello' })
|
||||
* ]
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function defineCli(config: CliConfig): CliConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
export { bashCommand } from './lib/builders/bash.js';
|
||||
export { nodeCommand } from './lib/builders/node.js';
|
||||
export { s3SyncCommand } from './lib/builders/s3Sync.js';
|
||||
export { env } from './lib/env.js';
|
||||
export * from './lib/types.js';
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { bashCommand } from './bash.js';
|
||||
|
||||
describe('bashCommand builder', () => {
|
||||
it('should return a properly formatted bash command config', () => {
|
||||
const config = bashCommand({
|
||||
name: 'hello',
|
||||
description: 'says hello',
|
||||
script: 'echo "hello"',
|
||||
});
|
||||
|
||||
expect(config.name).toBe('hello');
|
||||
expect(config.description).toBe('says hello');
|
||||
expect(config.script).toBe('echo "hello"');
|
||||
expect(config.type).toBe('bash');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { BashCommandConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* Creates a Bash command configuration.
|
||||
*
|
||||
* @param config - The command configuration excluding the 'type' field.
|
||||
* @returns The fully formatted Bash command configuration.
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = bashCommand({
|
||||
* name: 'hello',
|
||||
* description: 'says hello',
|
||||
* script: 'echo "hello"'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function bashCommand(
|
||||
config: Omit<BashCommandConfig, 'type'>
|
||||
): BashCommandConfig {
|
||||
return {
|
||||
...config,
|
||||
type: 'bash',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { nodeCommand } from './node.js';
|
||||
|
||||
describe('nodeCommand builder', () => {
|
||||
it('should return a properly formatted node command config', () => {
|
||||
const action = async () => {};
|
||||
const config = nodeCommand({
|
||||
name: 'build',
|
||||
description: 'builds stuff',
|
||||
action,
|
||||
});
|
||||
|
||||
expect(config.name).toBe('build');
|
||||
expect(config.description).toBe('builds stuff');
|
||||
expect(config.action).toBe(action);
|
||||
expect(config.type).toBe('node');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NodeCommandConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* Creates a Node command configuration.
|
||||
*
|
||||
* @param config - The command configuration excluding the 'type' field.
|
||||
* @returns The fully formatted Node command configuration.
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = nodeCommand({
|
||||
* name: 'run',
|
||||
* description: 'runs action',
|
||||
* action: () => console.log('hello')
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function nodeCommand(
|
||||
config: Omit<NodeCommandConfig, 'type'>
|
||||
): NodeCommandConfig {
|
||||
return {
|
||||
...config,
|
||||
type: 'node',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { s3SyncCommand } from './s3Sync.js';
|
||||
|
||||
describe('s3SyncCommand', () => {
|
||||
it('should create an s3-sync command config', () => {
|
||||
const config = s3SyncCommand({
|
||||
name: 'sync',
|
||||
description: 'Sync files',
|
||||
targetPrefix: 'www'
|
||||
});
|
||||
|
||||
expect(config.type).toBe('s3-sync');
|
||||
expect(config.name).toBe('sync');
|
||||
expect(config.targetPrefix).toBe('www');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { S3SyncCommandConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* Creates an S3 Sync command configuration.
|
||||
*
|
||||
* @param config - The command configuration excluding the 'type' field.
|
||||
* @returns The fully formatted S3 Sync command configuration.
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = s3SyncCommand({
|
||||
* name: 'sync',
|
||||
* description: 'Sync files',
|
||||
* targetPrefix: 'www'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function s3SyncCommand(
|
||||
config: Omit<S3SyncCommandConfig, 'type'>
|
||||
): S3SyncCommandConfig {
|
||||
return {
|
||||
...config,
|
||||
type: 's3-sync',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock dotenvx before importing env
|
||||
vi.mock('@dotenvx/dotenvx', () => ({
|
||||
config: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:fs so findRoot returns a predictable root
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn((p: string) => String(p).endsWith('pnpm-workspace.yaml')),
|
||||
}));
|
||||
|
||||
describe('env', () => {
|
||||
beforeEach(async () => {
|
||||
// Reset module registry so the `loaded` singleton resets between tests
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('calls dotenvx config() on first access with the root .env.dev path', async () => {
|
||||
const { config } = await import('@dotenvx/dotenvx');
|
||||
const { env } = await import('./env.js');
|
||||
|
||||
process.env.TEST_VAR = 'hello';
|
||||
env('TEST_VAR');
|
||||
|
||||
expect(config).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(config).mock.calls[0]![0]).toMatchObject({ path: expect.stringContaining('.env.dev') });
|
||||
delete process.env.TEST_VAR;
|
||||
});
|
||||
|
||||
it('returns the environment variable value', async () => {
|
||||
const { env } = await import('./env.js');
|
||||
|
||||
process.env.MY_KEY = 'my-value';
|
||||
expect(env('MY_KEY')).toBe('my-value');
|
||||
delete process.env.MY_KEY;
|
||||
});
|
||||
|
||||
it('returns the fallback when the variable is not set', async () => {
|
||||
const { env } = await import('./env.js');
|
||||
|
||||
delete process.env.MISSING_KEY;
|
||||
expect(env('MISSING_KEY', 'fallback-value')).toBe('fallback-value');
|
||||
});
|
||||
|
||||
it('returns undefined when the variable is not set and no fallback is provided', async () => {
|
||||
const { env } = await import('./env.js');
|
||||
|
||||
delete process.env.MISSING_KEY;
|
||||
expect(env('MISSING_KEY')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls dotenvx config() only once across multiple env() calls within the same import', async () => {
|
||||
const { config } = await import('@dotenvx/dotenvx');
|
||||
const mockConfig = vi.mocked(config);
|
||||
mockConfig.mockClear();
|
||||
|
||||
const { env } = await import('./env.js');
|
||||
|
||||
env('FOO');
|
||||
env('BAR');
|
||||
env('BAZ');
|
||||
|
||||
// config() should have been called exactly once for this fresh module instance
|
||||
expect(mockConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { config } from '@dotenvx/dotenvx';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
let loaded = false;
|
||||
|
||||
/**
|
||||
* Walks up the directory tree from `start` until it finds a directory
|
||||
* containing `pnpm-workspace.yaml` (the monorepo root), or reaches the
|
||||
* filesystem root.
|
||||
*
|
||||
* @param start - Directory to begin the search from.
|
||||
* @returns The path of the monorepo root directory, or `start` as a fallback.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
function findRoot(start: string): string {
|
||||
let dir = start;
|
||||
while (true) {
|
||||
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) {
|
||||
return dir;
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) {
|
||||
// Reached the filesystem root without finding the marker.
|
||||
return start;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures dotenvx has loaded `.env.dev` from the monorepo root into `process.env`.
|
||||
* Walks up from the current working directory to find the root, then loads the
|
||||
* `.env.dev` file located there. Safe to call multiple times — runs only once per
|
||||
* process.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
function ensureLoaded(): void {
|
||||
if (!loaded) {
|
||||
config({ path: join(findRoot(process.cwd()), '.env.dev') });
|
||||
loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an environment variable by key, loading `.env.dev` files via dotenvx on
|
||||
* the first call. Optionally returns a fallback value when the variable is
|
||||
* not set.
|
||||
*
|
||||
* Designed for use inside `cli.ts` config files so that env values are
|
||||
* resolved **at command execution time** (after dotenvx has populated
|
||||
* `process.env`), not at config-parse time.
|
||||
*
|
||||
* @param key - The name of the environment variable to read.
|
||||
* @param fallback - Optional fallback value returned when the variable is absent.
|
||||
* @returns The environment variable value, the fallback, or `undefined`.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { env } from '@ermnvldmr/cli';
|
||||
*
|
||||
* s3SyncCommand({
|
||||
* bucket: env('MY_BUCKET'),
|
||||
* region: env('AWS_REGION', 'us-east-1'),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function env(key: string, fallback?: string): string | undefined {
|
||||
ensureLoaded();
|
||||
return process.env[key] ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Command } from 'commander';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { registerCommands } from './runner.js';
|
||||
import { runS3Sync } from './runners/s3SyncRunner.js';
|
||||
|
||||
vi.mock('./runners/s3SyncRunner.js', () => ({
|
||||
runS3Sync: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('registerCommands', () => {
|
||||
it('registers commands into a Commander instance', async () => {
|
||||
const program = new Command();
|
||||
const actionSpy = vi.fn();
|
||||
|
||||
registerCommands(program, {
|
||||
commands: [
|
||||
{
|
||||
type: 'node',
|
||||
name: 'test-cmd',
|
||||
description: 'A test command',
|
||||
action: actionSpy,
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// We simulate parsing args to run the action. The registered action is
|
||||
// async, so parseAsync + await ensures it runs before we assert.
|
||||
await program.parseAsync(['node', 'script', 'test-cmd']);
|
||||
expect(actionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('registers s3-sync commands into a Commander instance', async () => {
|
||||
const program = new Command();
|
||||
const cmd = {
|
||||
type: 's3-sync' as const,
|
||||
name: 'sync-cmd',
|
||||
description: 'An S3 sync command',
|
||||
targetPrefix: 'my-site',
|
||||
};
|
||||
|
||||
registerCommands(program, {
|
||||
commands: [cmd]
|
||||
});
|
||||
|
||||
await program.parseAsync(['node', 'script', 'sync-cmd']);
|
||||
expect(runS3Sync).toHaveBeenCalledWith(cmd, expect.any(Object));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { execaCommand } from 'execa';
|
||||
import { createJiti } from 'jiti';
|
||||
|
||||
import type { CliConfig } from './types.js';
|
||||
|
||||
/**
|
||||
* Registers commands from the configuration into a Commander program.
|
||||
*
|
||||
* @param program - The Commander program instance.
|
||||
* @param config - The CLI configuration containing the commands to register.
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Command } from 'commander';
|
||||
* const program = new Command();
|
||||
* registerCommands(program, { commands: [] });
|
||||
* ```
|
||||
*/
|
||||
export function registerCommands(program: Command, config: CliConfig): void {
|
||||
for (const cmd of config.commands) {
|
||||
const command = program.command(cmd.name).description(cmd.description);
|
||||
|
||||
if (cmd.options) {
|
||||
for (const opt of cmd.options) {
|
||||
command.option(opt.flags, opt.description, opt.defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
switch (cmd.type) {
|
||||
case 'bash': {
|
||||
command.action(async () => {
|
||||
try {
|
||||
await execaCommand(cmd.script, { stdio: 'inherit', shell: true });
|
||||
} catch (err: unknown) {
|
||||
const isErrorWithExitCode = (e: unknown): e is { exitCode: number } => {
|
||||
return typeof e === 'object' && e !== null && 'exitCode' in e && typeof Reflect.get(e, 'exitCode') === 'number';
|
||||
};
|
||||
process.exit(isErrorWithExitCode(err) ? err.exitCode : 1);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'node': {
|
||||
command.action(async (options: Record<string, unknown>) => {
|
||||
try {
|
||||
await cmd.action(options);
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 's3-sync': {
|
||||
command.action(async (options: Record<string, unknown>) => {
|
||||
try {
|
||||
const { runS3Sync } = await import('./runners/s3SyncRunner.js');
|
||||
await runS3Sync(cmd, options);
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = cmd;
|
||||
void _exhaustiveCheck;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point function that loads the CLI configuration and executes the runner.
|
||||
*
|
||||
* @param cwd - Current working directory (defaults to process.cwd()).
|
||||
* @param argv - Process arguments (defaults to process.argv).
|
||||
* @example
|
||||
* ```typescript
|
||||
* await runCli();
|
||||
* ```
|
||||
*/
|
||||
export async function runCli(cwd: string = process.cwd(), argv: string[] = process.argv): Promise<void> {
|
||||
const configPath = path.join(cwd, '.config/cli.ts');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
console.error(`Error: No .config/cli.ts found in ${cwd}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const rawModule = await jiti.import(configPath);
|
||||
|
||||
const isCliConfigModule = (mod: unknown): mod is { default: CliConfig } => {
|
||||
return typeof mod === 'object' && mod !== null && 'default' in mod;
|
||||
};
|
||||
|
||||
if (!isCliConfigModule(rawModule)) {
|
||||
console.error(`Error: Invalid CLI configuration export in ${configPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = rawModule.default;
|
||||
|
||||
const program = new Command();
|
||||
program
|
||||
.name('ermnvldmr-cli')
|
||||
.description('Extensible CLI runner')
|
||||
.version('1.0.0');
|
||||
|
||||
registerCommands(program, config);
|
||||
|
||||
program.parse(argv);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { runS3Sync } from './s3SyncRunner.js';
|
||||
|
||||
import type { S3SyncCommandConfig } from '../types.js';
|
||||
|
||||
interface MockPutObjectCommandInput {
|
||||
Bucket: string;
|
||||
Key: string;
|
||||
Body: Buffer;
|
||||
ContentType: string;
|
||||
}
|
||||
|
||||
class MockPutObjectCommand {
|
||||
constructor(public input: MockPutObjectCommandInput) {}
|
||||
}
|
||||
|
||||
// Mock dotenvx
|
||||
vi.mock('@dotenvx/dotenvx', () => ({
|
||||
config: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock S3Client
|
||||
const mockSend = vi.fn<(command: MockPutObjectCommand) => Promise<unknown>>();
|
||||
vi.mock('@aws-sdk/client-s3', () => {
|
||||
return {
|
||||
S3Client: class {
|
||||
send = mockSend;
|
||||
},
|
||||
PutObjectCommand: class {
|
||||
constructor(public input: MockPutObjectCommandInput) {}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
describe('runS3Sync', () => {
|
||||
const tempDir = path.resolve(__dirname, 'temp_test_s3_sync');
|
||||
let cwdSpy: { mockRestore: () => void };
|
||||
|
||||
beforeEach(() => {
|
||||
mockSend.mockReset();
|
||||
|
||||
// Set required environment variables
|
||||
process.env.S3_BUCKET = 'my-bucket';
|
||||
process.env.AWS_REGION = 'us-east-1';
|
||||
process.env.S3_ENDPOINT_URL = 'https://s3.example.com';
|
||||
process.env.AWS_ACCESS_KEY_ID = 'access-key';
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'secret-key';
|
||||
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Mock process.cwd to return tempDir so glob search happens in tempDir/src
|
||||
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cwdSpy.mockRestore();
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error if required env variables are missing', async () => {
|
||||
const cmd: S3SyncCommandConfig = {
|
||||
name: 'sync',
|
||||
description: 'desc',
|
||||
targetPrefix: 'test',
|
||||
type: 's3-sync'
|
||||
};
|
||||
|
||||
// Clear env vars
|
||||
delete process.env.S3_BUCKET;
|
||||
|
||||
await expect(runS3Sync(cmd, {})).rejects.toThrow('Missing required S3 environment variables');
|
||||
});
|
||||
|
||||
it('should successfully sync files using default sourceDir and replace references in source files', async () => {
|
||||
// Default sourceDir is public/static
|
||||
const publicStaticDir = path.join(tempDir, 'public/static');
|
||||
fs.mkdirSync(publicStaticDir, { recursive: true });
|
||||
|
||||
// Create dummy source folder and files (where references will be replaced)
|
||||
const srcDir = path.join(tempDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
|
||||
const htmlSourcePath = path.join(srcDir, 'index.tsx');
|
||||
const cssSourcePath = path.join(srcDir, 'style.css');
|
||||
|
||||
fs.writeFileSync(htmlSourcePath, 'const img = "/static/logo.png";');
|
||||
fs.writeFileSync(cssSourcePath, 'body { background: url(/static/logo.png); }');
|
||||
|
||||
// Create dummy files inside the static directory to upload
|
||||
const imagePath = path.join(publicStaticDir, 'logo.png');
|
||||
fs.writeFileSync(imagePath, 'binary-content-mock');
|
||||
|
||||
const cmd: S3SyncCommandConfig = {
|
||||
name: 'sync',
|
||||
description: 'desc',
|
||||
targetPrefix: 'my-site',
|
||||
type: 's3-sync',
|
||||
};
|
||||
|
||||
// Run sync (which should default to public/static)
|
||||
await runS3Sync(cmd, {});
|
||||
|
||||
// Check if the HTML and CSS source file references were replaced
|
||||
const htmlContent = fs.readFileSync(htmlSourcePath, 'utf-8');
|
||||
const cssContent = fs.readFileSync(cssSourcePath, 'utf-8');
|
||||
|
||||
expect(htmlContent).toContain('https://s3.example.com/my-bucket/my-site/logo.png');
|
||||
expect(cssContent).toContain('https://s3.example.com/my-bucket/my-site/logo.png');
|
||||
|
||||
// Check if S3Client sent PutObjectCommands for the file
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
|
||||
const calls = mockSend.mock.calls;
|
||||
const commandInstance = calls[0][0];
|
||||
expect(commandInstance.input.Key).toBe('my-site/logo.png');
|
||||
});
|
||||
|
||||
it('should replace longer overlapping filenames before shorter ones', async () => {
|
||||
const publicStaticDir = path.join(tempDir, 'public/static');
|
||||
fs.mkdirSync(publicStaticDir, { recursive: true });
|
||||
|
||||
const srcDir = path.join(tempDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
|
||||
const sourcePath = path.join(srcDir, 'index.tsx');
|
||||
fs.writeFileSync(sourcePath, 'const img1 = "/static/logo.png.webp"; const img2 = "/static/logo.png";');
|
||||
|
||||
fs.writeFileSync(path.join(publicStaticDir, 'logo.png.webp'), 'webp-mock');
|
||||
fs.writeFileSync(path.join(publicStaticDir, 'logo.png'), 'png-mock');
|
||||
|
||||
const cmd: S3SyncCommandConfig = {
|
||||
name: 'sync',
|
||||
description: 'desc',
|
||||
targetPrefix: 'my-site',
|
||||
type: 's3-sync',
|
||||
};
|
||||
|
||||
await runS3Sync(cmd, {});
|
||||
|
||||
const content = fs.readFileSync(sourcePath, 'utf-8');
|
||||
expect(content).toContain('https://s3.example.com/my-bucket/my-site/logo.png.webp');
|
||||
expect(content).toContain('https://s3.example.com/my-bucket/my-site/logo.png');
|
||||
expect(content).not.toContain('/static/logo.png.webp');
|
||||
expect(content).not.toContain('/static/logo.png');
|
||||
});
|
||||
|
||||
it('should respect beforeUpload hook if provided', async () => {
|
||||
const publicStaticDir = path.join(tempDir, 'public/static');
|
||||
fs.mkdirSync(publicStaticDir, { recursive: true });
|
||||
|
||||
const htmlPath = path.join(publicStaticDir, 'index.html');
|
||||
fs.writeFileSync(htmlPath, '<html><body>Hello</body></html>');
|
||||
|
||||
const cmd: S3SyncCommandConfig = {
|
||||
name: 'sync',
|
||||
description: 'desc',
|
||||
targetPrefix: 'my-site',
|
||||
type: 's3-sync',
|
||||
hooks: {
|
||||
beforeUpload: (files) => {
|
||||
const processed = files.map(file => {
|
||||
const ext = path.extname(file);
|
||||
const base = path.basename(file, ext);
|
||||
return {
|
||||
originalName: path.basename(file),
|
||||
finalName: `custom-${base}${ext}`,
|
||||
absolutePath: file,
|
||||
};
|
||||
});
|
||||
return Promise.resolve(processed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await runS3Sync(cmd, {});
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const commandInstance = mockSend.mock.calls[0][0];
|
||||
expect(commandInstance.input.Key).toBe('my-site/custom-index.html');
|
||||
});
|
||||
|
||||
it('should resolve S3 configuration parameters passed directly via configuration object', async () => {
|
||||
const publicStaticDir = path.join(tempDir, 'public/static');
|
||||
fs.mkdirSync(publicStaticDir, { recursive: true });
|
||||
|
||||
// Write a dummy file to upload so the upload loop runs
|
||||
fs.writeFileSync(path.join(publicStaticDir, 'test.txt'), 'content');
|
||||
|
||||
// Clear standard env vars to ensure fallback is not used
|
||||
delete process.env.S3_BUCKET;
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.AWS_ACCESS_KEY_ID;
|
||||
delete process.env.AWS_SECRET_ACCESS_KEY;
|
||||
|
||||
const cmd: S3SyncCommandConfig = {
|
||||
name: 'sync',
|
||||
description: 'desc',
|
||||
targetPrefix: 'test',
|
||||
bucket: 'direct-bucket',
|
||||
region: 'us-west-2',
|
||||
accessKeyId: 'direct-access-key',
|
||||
secretAccessKey: 'direct-secret-key',
|
||||
type: 's3-sync'
|
||||
};
|
||||
|
||||
await runS3Sync(cmd, {});
|
||||
|
||||
expect(mockSend).toHaveBeenCalled();
|
||||
const commandInstance = mockSend.mock.calls[0][0];
|
||||
expect(commandInstance.input.Bucket).toBe('direct-bucket');
|
||||
});
|
||||
|
||||
it('should run beforeSync hook as shell command if provided as string', async () => {
|
||||
const publicStaticDir = path.join(tempDir, 'public/static');
|
||||
fs.mkdirSync(publicStaticDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(publicStaticDir, 'test.txt'), 'content');
|
||||
|
||||
const cmd: S3SyncCommandConfig = {
|
||||
name: 'sync',
|
||||
description: 'desc',
|
||||
targetPrefix: 'test',
|
||||
type: 's3-sync',
|
||||
hooks: {
|
||||
beforeSync: 'echo "hello from beforeSync script"',
|
||||
},
|
||||
};
|
||||
|
||||
await runS3Sync(cmd, {});
|
||||
expect(mockSend).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should run beforeSync hook as async function if provided as function', async () => {
|
||||
const publicStaticDir = path.join(tempDir, 'public/static');
|
||||
fs.mkdirSync(publicStaticDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(publicStaticDir, 'test.txt'), 'content');
|
||||
|
||||
const beforeSyncFn = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const cmd: S3SyncCommandConfig = {
|
||||
name: 'sync',
|
||||
description: 'desc',
|
||||
targetPrefix: 'test',
|
||||
type: 's3-sync',
|
||||
hooks: {
|
||||
beforeSync: beforeSyncFn,
|
||||
},
|
||||
};
|
||||
|
||||
await runS3Sync(cmd, {});
|
||||
expect(beforeSyncFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockSend).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should automatically map png/jpg/jpeg to webp if counterpart exists and deduplicate uploads', async () => {
|
||||
const publicStaticDir = path.join(tempDir, 'public/static');
|
||||
fs.mkdirSync(publicStaticDir, { recursive: true });
|
||||
|
||||
// Create png and webp counterpart
|
||||
fs.writeFileSync(path.join(publicStaticDir, 'logo.png'), 'png-content');
|
||||
fs.writeFileSync(path.join(publicStaticDir, 'logo.webp'), 'webp-content');
|
||||
|
||||
// Create a source file referencing the png
|
||||
const srcDir = path.join(tempDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
const sourcePath = path.join(srcDir, 'index.tsx');
|
||||
fs.writeFileSync(sourcePath, 'const img = "/static/logo.png";');
|
||||
|
||||
const cmd: S3SyncCommandConfig = {
|
||||
name: 'sync',
|
||||
description: 'desc',
|
||||
targetPrefix: 'test',
|
||||
type: 's3-sync',
|
||||
};
|
||||
|
||||
await runS3Sync(cmd, {});
|
||||
|
||||
// Reference replacement check: /static/logo.png should be replaced by S3 URL of logo.webp
|
||||
const content = fs.readFileSync(sourcePath, 'utf-8');
|
||||
expect(content).toContain('https://s3.example.com/my-bucket/test/logo.webp');
|
||||
expect(content).not.toContain('logo.png');
|
||||
|
||||
// Deduplication check: only logo.webp should have been uploaded once (mockSend called once for logo.webp)
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const commandInstance = mockSend.mock.calls[0][0];
|
||||
expect(commandInstance.input.Key).toBe('test/logo.webp');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { execaCommand } from 'execa';
|
||||
import fg from 'fast-glob';
|
||||
import mime from 'mime-types';
|
||||
|
||||
import type { S3SyncCommandConfig, ProcessedFile } from '../types.js';
|
||||
|
||||
/**
|
||||
* Normalizes prefix and name to construct a clean S3 key.
|
||||
*
|
||||
* @param prefix - The target directory prefix.
|
||||
* @param name - The relative path/name of the file.
|
||||
* @returns The clean, normalized S3 key.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* normalizeKey('static', 'js/main.js'); // 'static/js/main.js'
|
||||
* ```
|
||||
*/
|
||||
function normalizeKey(prefix: string, name: string): string {
|
||||
const parts = [prefix, name]
|
||||
.filter(Boolean)
|
||||
.map(p => p.replace(/^\/+|\/+$/g, '').replace(/\\/g, '/'));
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the S3 Sync command.
|
||||
*
|
||||
* Loads environment variables, instantiates S3Client, processes files,
|
||||
* replaces static asset references in source files (`/static/filename` -> S3 URL),
|
||||
* and uploads the assets to S3.
|
||||
*
|
||||
* @param cmd - The configuration for the S3 sync command.
|
||||
* @param options - Additional options passed from Commander.
|
||||
* @returns A promise that resolves when the synchronization is complete.
|
||||
* @throws An error if required environment variables are missing.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await runS3Sync(cmd, {});
|
||||
* ```
|
||||
*/
|
||||
export async function runS3Sync(
|
||||
cmd: S3SyncCommandConfig,
|
||||
options: Record<string, unknown> = {}
|
||||
): Promise<void> {
|
||||
void options;
|
||||
|
||||
const bucket = cmd.bucket ?? process.env.S3_BUCKET;
|
||||
const region = cmd.region ?? process.env.AWS_REGION ?? process.env.S3_REGION;
|
||||
const endpoint = cmd.endpoint ?? process.env.S3_ENDPOINT_URL ?? process.env.S3_ENDPOINT;
|
||||
const accessKey = cmd.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID ?? process.env.S3_ACCESS_KEY_ID;
|
||||
const secretKey = cmd.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY ?? process.env.S3_SECRET_ACCESS_KEY;
|
||||
|
||||
if (!bucket || !region || !accessKey || !secretKey) {
|
||||
throw new Error('Missing required S3 environment variables');
|
||||
}
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region,
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
credentials: {
|
||||
accessKeyId: accessKey,
|
||||
secretAccessKey: secretKey,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
if (cmd.hooks?.beforeSync) {
|
||||
const hook = cmd.hooks.beforeSync;
|
||||
if (typeof hook === 'string') {
|
||||
console.log(`Running beforeSync script: ${hook}`);
|
||||
await execaCommand(hook, { stdio: 'inherit', shell: true });
|
||||
} else {
|
||||
console.log('Running beforeSync hook...');
|
||||
await hook();
|
||||
}
|
||||
}
|
||||
|
||||
const sourceDir = cmd.sourceDir ?? './public/static';
|
||||
const resolvedSourceDir = path.resolve(process.cwd(), sourceDir);
|
||||
|
||||
if (!fs.existsSync(resolvedSourceDir)) {
|
||||
throw new Error(`Source directory does not exist: ${resolvedSourceDir}`);
|
||||
}
|
||||
|
||||
// Find all files in the source directory, including dotfiles
|
||||
const rawFiles = await fg('**/*', {
|
||||
cwd: resolvedSourceDir,
|
||||
onlyFiles: true,
|
||||
absolute: true,
|
||||
dot: true,
|
||||
});
|
||||
|
||||
// Process files through the hook if it exists, otherwise use the default mapping with auto-optimization detection
|
||||
let processedFiles: ProcessedFile[];
|
||||
if (cmd.hooks?.beforeUpload) {
|
||||
const rawProcessed = await cmd.hooks.beforeUpload(rawFiles);
|
||||
processedFiles = rawProcessed.map((file) => ({
|
||||
...file,
|
||||
originalName: file.originalName.replace(/\\/g, '/'),
|
||||
finalName: file.finalName.replace(/\\/g, '/'),
|
||||
}));
|
||||
} else {
|
||||
// Index raw files by normalized relative path
|
||||
const rawRels = new Set(
|
||||
rawFiles.map((file) => path.relative(resolvedSourceDir, file).replace(/\\/g, '/'))
|
||||
);
|
||||
|
||||
processedFiles = rawFiles.map((file) => {
|
||||
const rel = path.relative(resolvedSourceDir, file).replace(/\\/g, '/');
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
|
||||
if (['.png', '.jpg', '.jpeg'].includes(ext)) {
|
||||
const webpRel = rel.slice(0, -ext.length) + '.webp';
|
||||
if (rawRels.has(webpRel)) {
|
||||
// An optimized WebP version exists in the directory!
|
||||
// Map original reference to the WebP final upload path
|
||||
return {
|
||||
originalName: rel,
|
||||
finalName: webpRel,
|
||||
absolutePath: path.resolve(resolvedSourceDir, webpRel),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
originalName: rel,
|
||||
finalName: rel,
|
||||
absolutePath: file,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to construct the S3 URL for a final filename.
|
||||
*
|
||||
* @param finalName - The final processed name of the file.
|
||||
* @returns The public URL of the file on S3.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* getS3Url('js/main.js'); // 'https://my-bucket.s3.us-east-1.amazonaws.com/my-prefix/js/main.js'
|
||||
* ```
|
||||
*/
|
||||
const getS3Url = (finalName: string): string => {
|
||||
const normalizedKey = normalizeKey(cmd.targetPrefix, finalName);
|
||||
if (endpoint) {
|
||||
return `https://${bucket}/${normalizedKey}`;
|
||||
}
|
||||
return `https://${bucket}.s3.${region}.amazonaws.com/${normalizedKey}`;
|
||||
};
|
||||
|
||||
// Find workspace source files matching `src/**/*.{ts,tsx,css,md,mdx}`
|
||||
const sourceFiles = await fg('src/**/*.{ts,tsx,css,md,mdx}', {
|
||||
cwd: process.cwd(),
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
// Sort assets by length descending to handle overlapping name replacement correctly
|
||||
const sortedAssets = [...processedFiles].sort(
|
||||
(a, b) => b.originalName.length - a.originalName.length
|
||||
);
|
||||
|
||||
// Replace static asset references in workspace source files
|
||||
for (const sourceFile of sourceFiles) {
|
||||
let content = await fs.promises.readFile(sourceFile, 'utf-8');
|
||||
if (!content.includes('/static/')) {
|
||||
continue;
|
||||
}
|
||||
let modified = false;
|
||||
|
||||
for (const asset of sortedAssets) {
|
||||
let searchName = asset.originalName;
|
||||
if (searchName.startsWith('static/')) {
|
||||
searchName = searchName.substring('static/'.length);
|
||||
} else if (searchName.startsWith('/static/')) {
|
||||
searchName = searchName.substring('/static/'.length);
|
||||
}
|
||||
const searchStr = `/static/${searchName}`;
|
||||
const assetUrl = getS3Url(asset.finalName);
|
||||
|
||||
if (content.includes(searchStr)) {
|
||||
content = content.split(searchStr).join(assetUrl);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
await fs.promises.writeFile(sourceFile, content, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate files for S3 upload by their S3 key (finalName)
|
||||
const uniqueUploads = Array.from(
|
||||
new Map(processedFiles.map((file) => [file.finalName, file])).values()
|
||||
);
|
||||
|
||||
// Upload files to S3
|
||||
console.log(`Syncing ${uniqueUploads.length} unique files to s3://${bucket}/${cmd.targetPrefix}...`);
|
||||
for (const file of uniqueUploads) {
|
||||
const s3Key = normalizeKey(cmd.targetPrefix, file.finalName);
|
||||
const contentType = mime.lookup(file.absolutePath) || 'application/octet-stream';
|
||||
const fileContent = await fs.promises.readFile(file.absolutePath);
|
||||
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: s3Key,
|
||||
Body: fileContent,
|
||||
ContentType: contentType,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Base configuration interface for all CLI commands.
|
||||
*/
|
||||
export interface BaseCommandConfig {
|
||||
/** The name of the command */
|
||||
name: string;
|
||||
/** A brief description of what the command does */
|
||||
description: string;
|
||||
/** Optional array of command-line options/flags */
|
||||
options?: {
|
||||
/** The flag definitions, e.g., '-f, --force' */
|
||||
flags: string;
|
||||
/** Description of the option */
|
||||
description: string;
|
||||
/** The default value if the option is not provided */
|
||||
defaultValue?: string | boolean | string[];
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a Bash script based command.
|
||||
*/
|
||||
export interface BashCommandConfig extends BaseCommandConfig {
|
||||
/** The bash script or command to execute */
|
||||
script: string;
|
||||
/** The type discriminator for bash commands */
|
||||
type: 'bash';
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a Node.js function based command.
|
||||
*/
|
||||
export interface NodeCommandConfig extends BaseCommandConfig {
|
||||
/** The action function to execute when the command is run */
|
||||
action: (options: Record<string, unknown>) => Promise<void> | void;
|
||||
/** The type discriminator for node commands */
|
||||
type: 'node';
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a processed file ready for upload.
|
||||
*/
|
||||
export interface ProcessedFile {
|
||||
/** The original name of the file before processing */
|
||||
originalName: string;
|
||||
/** The final name of the file after processing, used as S3 key */
|
||||
finalName: string;
|
||||
/** The absolute path of the file on the local filesystem */
|
||||
absolutePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook value that can be either a shell command string or an async JS function.
|
||||
*
|
||||
* - **`string`** — executed as a shell command via the system shell.
|
||||
* - **`() => Promise<void>`** — called directly, useful for programmatic pre-processing.
|
||||
*/
|
||||
export type HookValue = string | (() => Promise<void>);
|
||||
|
||||
/**
|
||||
* Lifecycle hooks for the S3 sync command.
|
||||
*/
|
||||
export interface S3SyncHooks {
|
||||
/**
|
||||
* Runs before the sync process starts (before files are discovered).
|
||||
*
|
||||
* Accepts either a shell command string or an async function.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Shell command
|
||||
* hooks: { beforeSync: 'bash ./scripts/optimize-images.sh' }
|
||||
*
|
||||
* // JS function
|
||||
* hooks: { beforeSync: async () => { await optimizeImages(); } }
|
||||
* ```
|
||||
*/
|
||||
beforeSync?: HookValue;
|
||||
|
||||
/**
|
||||
* Runs after files are discovered but before they are uploaded.
|
||||
*
|
||||
* Receives the raw list of absolute file paths and must return the processed
|
||||
* file descriptors to upload. Useful for filtering, renaming, or replacing
|
||||
* files (e.g., swapping `.png` originals for their `.webp` counterparts).
|
||||
*
|
||||
* @param files - Absolute paths of all discovered files.
|
||||
* @returns The processed file list to upload.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* hooks: {
|
||||
* beforeUpload: async (files) =>
|
||||
* files.map((f) => ({ originalName: path.basename(f), finalName: path.basename(f), absolutePath: f }))
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
beforeUpload?: (files: string[]) => Promise<ProcessedFile[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for an S3 synchronization command.
|
||||
*/
|
||||
export interface S3SyncCommandConfig extends BaseCommandConfig {
|
||||
/** The type discriminator for S3 sync commands */
|
||||
type: 's3-sync';
|
||||
/** The source directory containing files to sync */
|
||||
sourceDir?: string;
|
||||
/** The prefix (directory) under which to upload the files in the S3 bucket */
|
||||
targetPrefix: string;
|
||||
/** Optional S3 bucket name */
|
||||
bucket?: string;
|
||||
/** Optional AWS region */
|
||||
region?: string;
|
||||
/** Optional custom endpoint URL (e.g., for MinIO or Yandex Cloud) */
|
||||
endpoint?: string;
|
||||
/** Optional AWS Access Key ID */
|
||||
accessKeyId?: string;
|
||||
/** Optional AWS Secret Access Key */
|
||||
secretAccessKey?: string;
|
||||
/** Hooks to modify the sync process at various lifecycle stages */
|
||||
hooks?: S3SyncHooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type representing any valid CLI command configuration.
|
||||
*/
|
||||
export type CliCommand = BashCommandConfig | NodeCommandConfig | S3SyncCommandConfig;
|
||||
|
||||
/**
|
||||
* Root configuration object for the CLI toolkit.
|
||||
*/
|
||||
export interface CliConfig {
|
||||
/** Array of registered commands */
|
||||
commands: CliCommand[];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { baseVitestConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
import { mergeConfig } from 'vitest/config';
|
||||
|
||||
export default mergeConfig(baseVitestConfig, {
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { join } from 'node:path';
|
||||
import fg from 'fast-glob';
|
||||
|
||||
export function discoverEntries(root: string, pattern: string): Record<string, string> {
|
||||
const files = fg.sync(pattern, { cwd: root });
|
||||
const entries: Record<string, string> = {};
|
||||
|
||||
for (const file of files) {
|
||||
// We expect the user to provide a pattern like './src/app/*.tsx'
|
||||
// We'll strip the directory parts to get the name
|
||||
const parts = file.split('/');
|
||||
// Remove extension from the last part
|
||||
const filename = parts[parts.length - 1].replace(/\.tsx$/, '');
|
||||
|
||||
// For nested routes, we might want to keep the path but relative to the app root
|
||||
// But since you want 'index', 'articles/index', etc.
|
||||
// Let's just use the file path relative to the common root 'src/app' if it exists in the path
|
||||
const appMatch = file.match(/src\/app\/(.*)\.tsx$/);
|
||||
let name = appMatch ? appMatch[1] : filename;
|
||||
|
||||
// If it's src/app/articles/index.tsx -> name is 'articles/index'
|
||||
// We want it to be 'articles/index' to generate 'dist/articles/index.html'
|
||||
// Rsbuild automatically maps 'articles/index' entry to 'dist/articles/index.html'
|
||||
// The previous replace(/\/index$/, '') was stripping the 'index' making it 'articles'
|
||||
// which generates 'dist/articles.html' instead of 'dist/articles/index.html'
|
||||
|
||||
entries[name] = join(root, file);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './rsbuild.js';
|
||||
export * from './vitest.js';
|
||||
export * from './entries.js';
|
||||
@@ -4,7 +4,6 @@ import { pluginTypeCheck } from '@rsbuild/plugin-type-check';
|
||||
import { pluginDts } from 'rsbuild-plugin-dts';
|
||||
|
||||
import { mergeConfig } from '../utils/merge.js';
|
||||
import { ssgPlugin } from './ssg.js';
|
||||
|
||||
/**
|
||||
* Defines a standard Rsbuild configuration for services (apps).
|
||||
@@ -22,7 +21,7 @@ import { ssgPlugin } from './ssg.js';
|
||||
*/
|
||||
export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
|
||||
const defaultConfig: RsbuildConfig = {
|
||||
plugins: [pluginReact(), pluginTypeCheck(), ssgPlugin()],
|
||||
plugins: [pluginReact(), pluginTypeCheck()],
|
||||
output: {
|
||||
distPath: {
|
||||
font: 'static/font',
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@ermnvldmr/ssg",
|
||||
"version": "0.0.1",
|
||||
"description": "Static Site Generation framework for ermnvldmr Rsbuild + React services",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./dev": {
|
||||
"import": "./dist/dev.js",
|
||||
"types": "./dist/dev.d.ts",
|
||||
"default": "./src/dev.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ermnvldmr/eslint-config": "workspace:*",
|
||||
"@ermnvldmr/rsbuild-config": "workspace:*",
|
||||
"@rsbuild/core": "^1.7.3",
|
||||
"@rsbuild/plugin-react": "^1.1.1",
|
||||
"@rsbuild/plugin-type-check": "^1.1.1",
|
||||
"@types/node": "*",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"critters": "^0.0.25",
|
||||
"fast-glob": "^3.3.3",
|
||||
"jsdom": "^28.0.0",
|
||||
"rsbuild-plugin-dts": "^0.19.5",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/dev.js';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/index.js';
|
||||
@@ -0,0 +1,36 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import fg from 'fast-glob';
|
||||
|
||||
/**
|
||||
* Discovers page entry files matching a glob pattern and maps them to Rsbuild entry names.
|
||||
*
|
||||
* Entry names follow the file path relative to `src/app/`, with the `.tsx` extension
|
||||
* stripped. For example, `./src/app/articles/index.tsx` becomes `articles/index`,
|
||||
* which Rsbuild maps to `dist/articles/index.html`.
|
||||
*
|
||||
* @param root - Absolute path to the service root directory (use `import.meta.dirname`).
|
||||
* @param pattern - Glob pattern relative to `root`, e.g. `'./src/app/**\/*.tsx'`.
|
||||
* @returns A record of entry name to absolute file path suitable for `source.entry`.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* source: {
|
||||
* entry: discoverEntries(import.meta.dirname, './src/app/**\/*.tsx'),
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function discoverEntries(root: string, pattern: string): Record<string, string> {
|
||||
const files = fg.sync(pattern, { cwd: root });
|
||||
const entries: Record<string, string> = {};
|
||||
|
||||
for (const file of files) {
|
||||
const parts = file.split('/');
|
||||
const filename = parts[parts.length - 1].replace(/\.tsx$/, '');
|
||||
const appMatch = /src\/app\/(.*)\.tsx$/.exec(file);
|
||||
const name = appMatch ? appMatch[1] : filename;
|
||||
entries[name] = join(root, file);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
+19
-26
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { RsbuildPlugin } from '@rsbuild/core';
|
||||
|
||||
/**
|
||||
@@ -14,6 +15,11 @@ import type { RsbuildPlugin } from '@rsbuild/core';
|
||||
* 3. Injects the rendered HTML into the web build's HTML files
|
||||
* 4. Runs critters to inline critical CSS and make the main stylesheet async
|
||||
* 5. Cleans up dist-ssr/
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* ssgPlugin()
|
||||
* ```
|
||||
*/
|
||||
export function ssgPlugin(): RsbuildPlugin {
|
||||
return {
|
||||
@@ -33,14 +39,14 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalise entries: value may be a string path or an EntryDescription object
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(rawEntries)) {
|
||||
if (typeof value === 'string') {
|
||||
entries[name] = value;
|
||||
} else if (value && typeof value === 'object' && 'import' in value) {
|
||||
} else if (typeof value === 'object' && 'import' in value) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const imp = (value as { import: string | string[] }).import;
|
||||
entries[name] = Array.isArray(imp) ? imp[0] : imp;
|
||||
entries[name] = Array.isArray(imp) ? (imp[0] ?? '') : imp;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,27 +56,24 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
// api.context.distPath is the absolute path to the web build output directory.
|
||||
// Fallback: derive from rootPath + output.distPath.root if the property is absent.
|
||||
const webDistPath: string =
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
(api.context as { distPath?: string }).distPath ??
|
||||
path.join(
|
||||
api.context.rootPath,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
(config.output?.distPath as { root?: string } | undefined)?.root ?? 'dist'
|
||||
);
|
||||
const ssrDistPath = path.join(path.dirname(webDistPath), 'dist-ssr');
|
||||
|
||||
// ── Step 1: Build node-target CJS bundles ─────────────────────────────
|
||||
console.log('[ssg-plugin] Building SSR bundles...');
|
||||
const { createRsbuild } = await import('@rsbuild/core');
|
||||
|
||||
// Reuse web build plugins minus ssgPlugin (avoids recursion) and pluginTypeCheck
|
||||
// (type-checking is not needed for the SSR build). This automatically includes
|
||||
// pluginReact, pluginMdx, and any other service-level plugins.
|
||||
const SSG_PLUGIN_NAME = 'ermnvldmr:ssg-plugin';
|
||||
const TYPE_CHECK_PLUGIN_NAME = 'rsbuild:type-check';
|
||||
const ssrPlugins = (config.plugins ?? []).filter((p) => {
|
||||
if (!p || typeof p !== 'object') return true;
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const name = (p as { name?: string }).name;
|
||||
return name !== SSG_PLUGIN_NAME && name !== TYPE_CHECK_PLUGIN_NAME;
|
||||
});
|
||||
@@ -84,8 +87,6 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
target: 'node',
|
||||
distPath: { root: ssrDistPath, js: '.' },
|
||||
filename: { js: '[name].cjs' },
|
||||
// Externalize React so the SSR bundle shares the same React instance
|
||||
// as react-dom/server, preventing "null dispatcher" hook errors.
|
||||
externals: {
|
||||
react: 'commonjs react',
|
||||
'react-dom': 'commonjs react-dom',
|
||||
@@ -99,12 +100,10 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
await ssrBuild.build();
|
||||
console.log('[ssg-plugin] SSR bundles ready.');
|
||||
|
||||
// ── Step 2: Render, inject, inline critical CSS ───────────────────────
|
||||
const { renderToString } = await import('react-dom/server');
|
||||
|
||||
// createRequire from ESM context — needed to load CJS modules (critters, SSR bundles)
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const Critters = _require('critters') as new (opts?: {
|
||||
path?: string;
|
||||
publicPath?: string;
|
||||
@@ -114,28 +113,27 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
reduceInlineStyles?: boolean;
|
||||
}) => { process(html: string): Promise<string> };
|
||||
|
||||
// critters `path` is the filesystem root that corresponds to publicPath.
|
||||
// It resolves <link href="..."> to webDistPath. Use configured assetPrefix for locale builds.
|
||||
const configuredAssetPrefix =
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
(config.output as { assetPrefix?: string } | undefined)?.assetPrefix ?? '/';
|
||||
|
||||
const critters = new Critters({
|
||||
path: webDistPath,
|
||||
publicPath: configuredAssetPrefix,
|
||||
preload: 'swap', // <link rel="preload" as="style" onload="this.rel='stylesheet'">
|
||||
inlineFonts: false, // fonts are handled via separate <link rel="preload"> tags
|
||||
pruneSource: false, // keep full CSS file for post-hydration correctness
|
||||
reduceInlineStyles: false, // preserve theme init styles (prevents .dark rule from being stripped)
|
||||
preload: 'swap',
|
||||
inlineFonts: false,
|
||||
pruneSource: false,
|
||||
reduceInlineStyles: false,
|
||||
});
|
||||
|
||||
for (const entryName of entryNames) {
|
||||
const ssrBundlePath = path.join(ssrDistPath, `${entryName}.cjs`);
|
||||
const webHtmlPath = path.join(webDistPath, `${entryName}.html`);
|
||||
|
||||
// Clear module cache so each entry loads fresh
|
||||
const resolved = _require.resolve(ssrBundlePath);
|
||||
delete _require.cache[resolved];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const mod = _require(ssrBundlePath) as { default?: React.ComponentType };
|
||||
const Component = mod.default;
|
||||
|
||||
@@ -144,12 +142,10 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Render to HTML string
|
||||
const renderedHtml = renderToString(
|
||||
React.createElement(React.StrictMode, null, React.createElement(Component))
|
||||
);
|
||||
|
||||
// Read web HTML, inject rendered content
|
||||
const source = await fs.readFile(webHtmlPath, 'utf8');
|
||||
const injected = source.replace(
|
||||
/<div id="root"[^>]*>\s*<\/div>/,
|
||||
@@ -162,10 +158,8 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
);
|
||||
}
|
||||
|
||||
// Run critters: inline critical CSS, make main stylesheet async
|
||||
const processed = await critters.process(injected);
|
||||
|
||||
// Set <html lang> to the current build locale
|
||||
const locale = process.env.LOCALE ?? 'en';
|
||||
const localized = processed.replace(
|
||||
/<html([^>]*)\slang="[^"]*"/,
|
||||
@@ -176,7 +170,6 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
console.log(`[ssg-plugin] ✓ ${entryName}`);
|
||||
}
|
||||
|
||||
// ── Step 3: Cleanup ───────────────────────────────────────────────────
|
||||
await fs.rm(ssrDistPath, { recursive: true, force: true });
|
||||
console.log('[ssg-plugin] SSG complete. dist-ssr cleaned up.');
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineServiceConfig, mergeConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
import { type RsbuildConfig } from '@rsbuild/core';
|
||||
|
||||
import { ssgPlugin } from './ssg.js';
|
||||
|
||||
/**
|
||||
* Defines a complete Rsbuild configuration for SSG services.
|
||||
*
|
||||
* Wraps `defineServiceConfig` from `@ermnvldmr/rsbuild-config` and pre-injects
|
||||
* `ssgPlugin()` so consumers do not need to wire it manually.
|
||||
*
|
||||
* @param config - Optional Rsbuild configuration overrides.
|
||||
* @returns A complete Rsbuild configuration object.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { defineSSGServiceConfig, discoverEntries } from '@ermnvldmr/ssg/dev';
|
||||
*
|
||||
* export default defineSSGServiceConfig({
|
||||
* source: { entry: discoverEntries(import.meta.dirname, './src/app/**\/*.tsx') },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function defineSSGServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
|
||||
return defineServiceConfig(mergeConfig(config, { plugins: [ssgPlugin()] }));
|
||||
}
|
||||
+10
-4
@@ -1,20 +1,26 @@
|
||||
import React from 'react';
|
||||
import { createRoot, hydrateRoot } from 'react-dom/client';
|
||||
import '../../static/global.css';
|
||||
|
||||
/**
|
||||
* Options for page creation
|
||||
* Options for page creation.
|
||||
*/
|
||||
export interface PageOptions {
|
||||
/** Document title to set on mount. */
|
||||
title?: string;
|
||||
/** Content of the meta description tag. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a React page component with hydration support.
|
||||
* Mounts or hydrates a React component into the `#root` element.
|
||||
*
|
||||
* Skips DOM mounting during the SSG server-side render pass
|
||||
* (`typeof window === 'undefined'`). On the client, hydrates if the
|
||||
* root already has children (SSG output), otherwise performs a fresh
|
||||
* `createRoot` render.
|
||||
*
|
||||
* @param Component - The React component to render.
|
||||
* @param options - Page options like title.
|
||||
* @param options - Optional page metadata (title, description).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './config/ssg.js';
|
||||
export * from './config/entries.js';
|
||||
export * from './config/ssgServiceConfig.js';
|
||||
export { mergeConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './core/createPage.js';
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"composite": true,
|
||||
"declarationMap": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -9,7 +9,10 @@ const rbFontSizeClasses = RB_TYPES.flatMap((type) => RB_SIZES.map((size) => `${t
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
'font-size': [{ 'text-rb': rbFontSizeClasses }],
|
||||
'font-size': [
|
||||
{ 'text-rb': rbFontSizeClasses },
|
||||
{ text: rbFontSizeClasses },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
import { useAccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
|
||||
|
||||
import type { AccordionContentProps } from '../types';
|
||||
|
||||
const contentVariants = cva('grid transition-[grid-template-rows] duration-200 ease-out', {
|
||||
variants: {
|
||||
isOpen: {
|
||||
true: 'grid-rows-[1fr]',
|
||||
false: 'grid-rows-[0fr]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isOpen: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The collapsible content of an Accordion item.
|
||||
*
|
||||
@@ -18,11 +31,7 @@ export const AccordionContent = ({ children, className }: AccordionContentProps)
|
||||
<div
|
||||
aria-hidden={!isOpen}
|
||||
aria-labelledby={`accordion-trigger-${value}`}
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] duration-200 ease-out',
|
||||
isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
className
|
||||
)}
|
||||
className={cn(contentVariants({ isOpen }), className)}
|
||||
id={`accordion-content-${value}`}
|
||||
inert={!isOpen ? true : undefined}
|
||||
role="region"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useButton } from 'react-aria';
|
||||
@@ -10,6 +11,36 @@ import { useAccordionItemContext } from '../contexts/AccordionItemContext/Accord
|
||||
|
||||
import type { AccordionTriggerProps } from '../types';
|
||||
|
||||
const triggerVariants = cva(
|
||||
'group flex w-full items-center justify-between py-4 text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--rb-ring)] focus-visible:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
isDisabled: {
|
||||
true: 'cursor-not-allowed opacity-50',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isDisabled: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const iconVariants = cva(
|
||||
'size-5 shrink-0 transition-transform duration-200 text-[var(--rb-outline)]',
|
||||
{
|
||||
variants: {
|
||||
isOpen: {
|
||||
true: 'rotate-90',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isOpen: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* The interactive trigger for an Accordion item.
|
||||
*
|
||||
@@ -52,19 +83,10 @@ export const AccordionTrigger = ({
|
||||
<button
|
||||
{...buttonProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group flex w-full items-center justify-between py-4 text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--rb-ring)] focus-visible:ring-offset-2',
|
||||
isDisabled && 'cursor-not-allowed opacity-50',
|
||||
className
|
||||
)}
|
||||
className={cn(triggerVariants({ isDisabled }), className)}
|
||||
>
|
||||
<HStack align="center" className="w-full" gap={2}>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-5 shrink-0 transition-transform duration-200 text-[var(--rb-outline)]',
|
||||
isOpen && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<ChevronRight className={cn(iconVariants({ isOpen }))} />
|
||||
<span className="flex-1">{children}</span>
|
||||
</HStack>
|
||||
</button>
|
||||
|
||||
@@ -33,7 +33,7 @@ export const Showcase: Story = {
|
||||
/>
|
||||
<BentoGrid.InfoCard
|
||||
description="Built on top of Rsbuild and Rspack for near-instant compilation and ultra-fast HMR."
|
||||
icon={<Zap className="text-rb-orange-500" size={24} />}
|
||||
icon={<Zap className="text-orange-500" size={24} />}
|
||||
title="Lightning Fast"
|
||||
/>
|
||||
<BentoGrid.CTACard
|
||||
@@ -46,7 +46,7 @@ export const Showcase: Story = {
|
||||
<BentoGrid.InfoCard
|
||||
description="Strict TypeScript integration across the entire monorepo for maximum reliability."
|
||||
footer={<div className="text-xs opacity-50">Strict Mode Enabled</div>}
|
||||
icon={<Shield className="text-rb-green-500" size={24} />}
|
||||
icon={<Shield className="text-green-500" size={24} />}
|
||||
title="Type Safe"
|
||||
/>
|
||||
<BentoGrid.ImageCard
|
||||
@@ -57,7 +57,7 @@ export const Showcase: Story = {
|
||||
/>
|
||||
<BentoGrid.InfoCard
|
||||
description="Easily extend and customize the design system with our modular component library."
|
||||
icon={<Cpu className="text-rb-blue-500" size={24} />}
|
||||
icon={<Cpu className="text-blue-500" size={24} />}
|
||||
title="Modular Architecture"
|
||||
/>
|
||||
<BentoGrid.CTACard
|
||||
@@ -66,18 +66,18 @@ export const Showcase: Story = {
|
||||
href="#"
|
||||
title="Documentation"
|
||||
/>
|
||||
<BentoGrid.BaseCard className="bg-rb-tertiary-base/10 border-dashed" colSpan={4}>
|
||||
<BentoGrid.BaseCard className="bg-tertiary/10 border-dashed" colSpan={4}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-rb-tertiary-base rounded-full text-rb-tertiary-text">
|
||||
<div className="p-3 bg-tertiary rounded-full text-tertiary-text">
|
||||
<Send size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-rb-text">Subscribe to Newsletter</h4>
|
||||
<p className="text-sm text-rb-muted-text">Stay updated with the latest releases and technical articles.</p>
|
||||
<h4 className="font-bold text-text">Subscribe to Newsletter</h4>
|
||||
<p className="text-sm text-muted-text">Stay updated with the latest releases and technical articles.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-6 py-2 bg-rb-text text-rb-base rounded-md font-medium">Subscribe</button>
|
||||
<button className="px-6 py-2 bg-text text-[var(--rb-base)] rounded-md font-medium">Subscribe</button>
|
||||
</div>
|
||||
</BentoGrid.BaseCard>
|
||||
</BentoGrid>
|
||||
@@ -91,31 +91,31 @@ export const LayoutPatterns: Story = {
|
||||
<section>
|
||||
<h3 className="mb-4 text-xl font-bold">Standard 4-Column Grid</h3>
|
||||
<BentoGrid>
|
||||
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
</BentoGrid>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-4 text-xl font-bold">Mixed Spans (Hero Pattern)</h3>
|
||||
<BentoGrid>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-primary-base/20 border-rb-primary-base" colSpan={2} rowSpan={2}>2x2 Hero</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-primary/20 border-primary" colSpan={2} rowSpan={2}>2x2 Hero</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
</BentoGrid>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-4 text-xl font-bold">Tall & Wide Pattern</h3>
|
||||
<BentoGrid>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-secondary-base/20 border-rb-secondary-base" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-tertiary-base/20 border-rb-tertiary-base" rowSpan={2}>1x2 Tall</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100" colSpan={2}>2x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-secondary/20 border-secondary" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-tertiary/20 border-tertiary" rowSpan={2}>1x2 Tall</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
|
||||
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100" colSpan={2}>2x1</BentoGrid.BaseCard>
|
||||
</BentoGrid>
|
||||
</section>
|
||||
</div>
|
||||
@@ -155,20 +155,20 @@ export const AllCardTypes: Story = {
|
||||
colSpan={2}
|
||||
defaultValue={['features']}
|
||||
description="A specialized card for categorized lists, ideal for wishlists or feature matrices."
|
||||
icon={<div className="p-2 bg-rb-blue-100 text-rb-blue-700 rounded-lg"><Star size={20} /></div>}
|
||||
icon={<div className="p-2 bg-blue-100 text-blue-700 rounded-lg"><Star size={20} /></div>}
|
||||
sections={[
|
||||
{
|
||||
value: 'features',
|
||||
label: 'Features',
|
||||
labelIcon: <Check size={18} />,
|
||||
labelClassName: 'text-rb-green-600',
|
||||
labelClassName: 'text-green-600',
|
||||
items: ['Responsive', 'Accessible', 'Themeable'],
|
||||
}
|
||||
]}
|
||||
title="List Card"
|
||||
/>
|
||||
<BentoGrid.BaseCard className="flex flex-col gap-4">
|
||||
<div className="p-2 bg-rb-color-yellow-100 text-rb-color-yellow-900 rounded-md w-fit">
|
||||
<div className="p-2 bg-yellow-100 text-yellow-900 rounded-md w-fit">
|
||||
<Code size={20} />
|
||||
</div>
|
||||
<h4 className="font-bold">Base Card (Custom)</h4>
|
||||
@@ -184,7 +184,7 @@ export const WishlistVariant: Story = {
|
||||
<div className="w-full max-w-6xl p-4">
|
||||
<div className="mb-8">
|
||||
<Header level={2}>My Wishlist</Header>
|
||||
<Text className="text-rb-muted-text" size="l">Things I want and things I already have, organized by category.</Text>
|
||||
<Text className="text-muted-text" size="l">Things I want and things I already have, organized by category.</Text>
|
||||
</div>
|
||||
|
||||
<BentoGrid>
|
||||
@@ -193,7 +193,7 @@ export const WishlistVariant: Story = {
|
||||
defaultValue={['wanted']}
|
||||
description="My essential list of gadgets and hardware I use for daily productivity and entertainment."
|
||||
icon={
|
||||
<div className="p-3 bg-rb-blue-100 text-rb-blue-700 rounded-xl">
|
||||
<div className="p-3 bg-blue-100 text-blue-700 rounded-xl">
|
||||
<Gamepad2 size={24} />
|
||||
</div>
|
||||
}
|
||||
@@ -203,7 +203,7 @@ export const WishlistVariant: Story = {
|
||||
value: 'wanted',
|
||||
label: 'Wanted',
|
||||
labelIcon: <Gift size={18} />,
|
||||
labelClassName: 'text-rb-orange-600',
|
||||
labelClassName: 'text-orange-600',
|
||||
items: [
|
||||
'Steam Deck OLED 1TB',
|
||||
'Sony A7C II with 35mm lens',
|
||||
@@ -214,7 +214,7 @@ export const WishlistVariant: Story = {
|
||||
value: 'owned',
|
||||
label: 'Already Owned',
|
||||
labelIcon: <Check size={18} />,
|
||||
labelClassName: 'text-rb-green-600',
|
||||
labelClassName: 'text-green-600',
|
||||
items: [
|
||||
'MacBook Pro M3 Max',
|
||||
'Sony WH-1000XM5 Headphones',
|
||||
@@ -229,7 +229,7 @@ export const WishlistVariant: Story = {
|
||||
colSpan={2}
|
||||
description="Places I want to visit and explore around the world."
|
||||
icon={
|
||||
<div className="p-3 bg-rb-green-100 text-rb-green-700 rounded-xl">
|
||||
<div className="p-3 bg-green-100 text-green-700 rounded-xl">
|
||||
<Plane size={24} />
|
||||
</div>
|
||||
}
|
||||
@@ -238,7 +238,7 @@ export const WishlistVariant: Story = {
|
||||
value: 'wanted',
|
||||
label: 'Bucket List',
|
||||
labelIcon: <Gift size={18} />,
|
||||
labelClassName: 'text-rb-orange-600',
|
||||
labelClassName: 'text-orange-600',
|
||||
items: [
|
||||
'Kyoto, Japan (Cherry Blossom Season)',
|
||||
'Reykjavik, Iceland',
|
||||
@@ -249,7 +249,7 @@ export const WishlistVariant: Story = {
|
||||
value: 'owned',
|
||||
label: 'Visited',
|
||||
labelIcon: <Check size={18} />,
|
||||
labelClassName: 'text-rb-green-600',
|
||||
labelClassName: 'text-green-600',
|
||||
items: [
|
||||
'Paris, France',
|
||||
'Rome, Italy',
|
||||
@@ -265,7 +265,7 @@ export const WishlistVariant: Story = {
|
||||
colSpan={2}
|
||||
description="My reading backlog spanning technical books, sci-fi, and fantasy."
|
||||
icon={
|
||||
<div className="p-3 bg-rb-violet-100 text-rb-violet-700 rounded-xl">
|
||||
<div className="p-3 bg-violet-100 text-violet-700 rounded-xl">
|
||||
<BookOpen size={24} />
|
||||
</div>
|
||||
}
|
||||
@@ -274,7 +274,7 @@ export const WishlistVariant: Story = {
|
||||
value: 'wanted',
|
||||
label: 'Reading List',
|
||||
labelIcon: <Gift size={18} />,
|
||||
labelClassName: 'text-rb-orange-600',
|
||||
labelClassName: 'text-orange-600',
|
||||
items: [
|
||||
'Designing Data-Intensive Applications',
|
||||
'The Pragmatic Programmer',
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('BentoGrid', () => {
|
||||
);
|
||||
|
||||
const title = screen.getByText('Primary CTA');
|
||||
const card = title.closest('.bg-rb-primary-base');
|
||||
const card = title.closest('.bg-primary');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,31 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
import type { BentoCardBaseProps } from './types';
|
||||
|
||||
/**
|
||||
* Utility to generate Tailwind grid span classes based on props.
|
||||
* @example Generating classes:
|
||||
* ```ts
|
||||
* const classes = getSpanClasses({ colSpan: 2 });
|
||||
* ```
|
||||
*/
|
||||
const getSpanClasses = ({ colSpan = 1, rowSpan = 1 }: BentoCardBaseProps) => {
|
||||
const colSpans = {
|
||||
1: 'md:col-span-1',
|
||||
2: 'md:col-span-2',
|
||||
3: 'md:col-span-3',
|
||||
4: 'md:col-span-4',
|
||||
};
|
||||
|
||||
const rowSpans = {
|
||||
1: 'md:row-span-1',
|
||||
2: 'md:row-span-2',
|
||||
3: 'md:row-span-3',
|
||||
};
|
||||
|
||||
return cn(colSpans[colSpan], rowSpans[rowSpan]);
|
||||
};
|
||||
const baseCardVariants = cva(
|
||||
'relative overflow-hidden rounded-xl border border-outline bg-base p-6 shadow-sm transition-all hover:shadow-md',
|
||||
{
|
||||
variants: {
|
||||
colSpan: {
|
||||
1: 'md:col-span-1',
|
||||
2: 'md:col-span-2',
|
||||
3: 'md:col-span-3',
|
||||
4: 'md:col-span-4',
|
||||
},
|
||||
rowSpan: {
|
||||
1: 'md:row-span-1',
|
||||
2: 'md:row-span-2',
|
||||
3: 'md:row-span-3',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
colSpan: 1,
|
||||
rowSpan: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Base card component that provides the foundational styling for all Bento cards.
|
||||
@@ -36,18 +36,20 @@ export const BentoGridBaseCard = ({
|
||||
className,
|
||||
colSpan,
|
||||
rowSpan,
|
||||
href,
|
||||
...props
|
||||
}: BentoCardBaseProps) => {
|
||||
// Render as an anchor when `href` is provided so the whole card is a link,
|
||||
// otherwise a plain div. Styling and span variants are identical either way.
|
||||
const Component: React.ElementType = href != null ? 'a' : 'div';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-xl border border-rb-outline bg-rb-base p-6 shadow-sm transition-all hover:shadow-md',
|
||||
getSpanClasses({ colSpan, rowSpan }),
|
||||
className
|
||||
)}
|
||||
<Component
|
||||
className={cn(baseCardVariants({ colSpan, rowSpan }), href != null && 'block', className)}
|
||||
href={href}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
import { BentoGridBaseCard } from './BentoGridBaseCard';
|
||||
@@ -9,6 +10,42 @@ import { VStack } from '../VStack/VStack';
|
||||
|
||||
import type { BentoCardBaseProps } from './types';
|
||||
|
||||
const cardVariants = cva('', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
primary: 'bg-primary border-primary',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
const headerVariants = cva('', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
primary: 'text-primary-text',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
const descriptionVariants = cva('', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'text-muted-text',
|
||||
primary: 'text-primary-text/80',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
@@ -45,18 +82,15 @@ export const BentoGridCTACard = ({
|
||||
return (
|
||||
<BentoGridBaseCard
|
||||
{...baseProps}
|
||||
className={cn(
|
||||
variant === 'primary' && 'bg-rb-primary-base border-rb-primary-base',
|
||||
className
|
||||
)}
|
||||
className={cn(cardVariants({ variant }), className)}
|
||||
>
|
||||
<VStack className="h-full justify-between" gap={4}>
|
||||
<VStack gap={2}>
|
||||
<Header className={cn(variant === 'primary' && 'text-rb-primary-text')} level={3}>
|
||||
<Header className={cn(headerVariants({ variant }))} level={3}>
|
||||
{title}
|
||||
</Header>
|
||||
<Text
|
||||
className={cn(variant === 'primary' ? 'text-rb-primary-text/80' : 'text-rb-muted-text')}
|
||||
className={cn(descriptionVariants({ variant }))}
|
||||
size="m"
|
||||
>
|
||||
{description}
|
||||
|
||||
@@ -36,11 +36,11 @@ export const BentoGridInfoCard = ({
|
||||
<BentoGridBaseCard {...baseProps}>
|
||||
<VStack className="h-full justify-between" gap={4}>
|
||||
<VStack gap={2}>
|
||||
{icon && <div className="text-rb-primary-text">{icon}</div>}
|
||||
{icon && <div className="text-primary-text">{icon}</div>}
|
||||
<Header level={3}>{title}</Header>
|
||||
{description && <Text className="text-rb-muted-text" size="m">{description}</Text>}
|
||||
{description && <Text className="text-muted-text" size="m">{description}</Text>}
|
||||
</VStack>
|
||||
{footer && <div className="mt-auto">{footer}</div>}
|
||||
{footer && <div className="mt-auto w-full">{footer}</div>}
|
||||
</VStack>
|
||||
</BentoGridBaseCard>
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ export const BentoGridListCard = ({
|
||||
</HStack>
|
||||
|
||||
{description && (
|
||||
<Text className="text-rb-muted-text">
|
||||
<Text className="text-muted-text">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,11 @@ import type React from 'react';
|
||||
|
||||
/**
|
||||
* Common props for all BentoGrid card variations.
|
||||
*
|
||||
* Typed against `HTMLElement` since a card can render as either a `div` or,
|
||||
* when `href` is provided, an `a`.
|
||||
*/
|
||||
export interface BentoCardBaseProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
export interface BentoCardBaseProps extends React.HTMLAttributes<HTMLElement> {
|
||||
/**
|
||||
* Number of columns to span on medium screens and up.
|
||||
* @default 1
|
||||
@@ -22,6 +25,11 @@ export interface BentoCardBaseProps extends React.HTMLAttributes<HTMLDivElement>
|
||||
* The content of the card.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* If provided, the card renders as an anchor (`<a>`) so the entire card is a
|
||||
* navigable link. Card styling and colSpan/rowSpan are preserved.
|
||||
*/
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { Text } from '../Text/Text';
|
||||
@@ -18,6 +19,28 @@ export interface BlockquoteProps
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
const blockquoteVariants = cva('border-l-4 border-[var(--rb-primary)]', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
inset: 'bg-[var(--rb-surface-variant)] p-6 rounded-r-lg',
|
||||
},
|
||||
indent: {
|
||||
true: 'pl-6',
|
||||
false: '',
|
||||
},
|
||||
gutterBottom: {
|
||||
true: 'mb-6',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
indent: true,
|
||||
gutterBottom: false,
|
||||
},
|
||||
});
|
||||
|
||||
const BlockquoteComponent = forwardRef<HTMLQuoteElement, BlockquoteProps>(function Blockquote(
|
||||
{
|
||||
children,
|
||||
@@ -34,13 +57,7 @@ const BlockquoteComponent = forwardRef<HTMLQuoteElement, BlockquoteProps>(functi
|
||||
<blockquote
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-l-4 border-[var(--rb-primary)]',
|
||||
indent && 'pl-6',
|
||||
gutterBottom && 'mb-6',
|
||||
variant === 'inset' && 'bg-[var(--rb-surface-variant)] p-6 rounded-r-lg',
|
||||
className
|
||||
)}
|
||||
className={cn(blockquoteVariants({ variant, indent, gutterBottom }), className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
@@ -46,6 +47,36 @@ export interface BreadcrumbsItemProps extends TestIdProps {
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
const itemVariants = cva('group flex items-center transition-all duration-200', {
|
||||
variants: {
|
||||
isCurrent: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
isClickable: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [{ isCurrent: false, isClickable: true, className: 'cursor-pointer' }],
|
||||
defaultVariants: {
|
||||
isCurrent: false,
|
||||
isClickable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const itemTextVariants = cva('transition-all duration-200', {
|
||||
variants: {
|
||||
isCurrent: {
|
||||
true: '',
|
||||
false: 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isCurrent: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Individual step in the breadcrumb trail.
|
||||
*/
|
||||
@@ -63,19 +94,12 @@ const BreadcrumbsItem = memo(function BreadcrumbsItem({
|
||||
return (
|
||||
<li
|
||||
aria-current={isCurrent ? 'page' : undefined}
|
||||
className={cn(
|
||||
'group flex items-center transition-all duration-200',
|
||||
!isCurrent && isClickable && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
className={cn(itemVariants({ isCurrent, isClickable }), className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Text
|
||||
as={resolvedHref ? 'a' : onClick ? 'button' : 'span'}
|
||||
className={cn(
|
||||
'transition-all duration-200',
|
||||
!isCurrent && 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4'
|
||||
)}
|
||||
className={cn(itemTextVariants({ isCurrent }))}
|
||||
color={isCurrent ? 'default' : 'muted'}
|
||||
href={resolvedHref}
|
||||
size="m"
|
||||
|
||||
@@ -79,4 +79,16 @@ describe('components/Button', () => {
|
||||
render(<Button fullWidth>Full Width</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('w-full');
|
||||
});
|
||||
|
||||
it('applies correct variant and color classes', () => {
|
||||
const { rerender } = render(<Button color="primary" variant="solid">Primary Solid</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-primary');
|
||||
expect(screen.getByRole('button')).toHaveClass('text-primary-text');
|
||||
|
||||
rerender(<Button color="secondary" variant="outline">Secondary Outline</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
||||
expect(screen.getByRole('button')).toHaveClass('border-secondary');
|
||||
expect(screen.getByRole('button')).toHaveClass('text-secondary');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef, useRef } from 'react';
|
||||
import { useButton, useLink, useFocusRing, useHover } from 'react-aria';
|
||||
|
||||
@@ -77,6 +78,109 @@ interface LinkButtonProps
|
||||
*/
|
||||
export type ButtonProps = ActionButtonProps | LinkButtonProps;
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden cursor-pointer active:scale-[0.98]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
solid: '',
|
||||
outline: '',
|
||||
ghost: '',
|
||||
link: '',
|
||||
},
|
||||
color: {
|
||||
primary: '',
|
||||
'primary-negative': '',
|
||||
secondary: '',
|
||||
'secondary-negative': '',
|
||||
tertiary: '',
|
||||
'tertiary-negative': '',
|
||||
error: '',
|
||||
'error-negative': '',
|
||||
neutral: '',
|
||||
'neutral-negative': '',
|
||||
},
|
||||
size: {
|
||||
s: 'h-8 px-3 text-sm gap-1.5',
|
||||
m: 'h-10 px-4 text-base gap-2',
|
||||
l: 'h-12 px-6 text-lg gap-3',
|
||||
},
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
md: 'rounded-md',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
fullWidth: {
|
||||
true: 'w-full',
|
||||
false: '',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'opacity-50 cursor-not-allowed grayscale-[0.5] active:scale-100',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Primary
|
||||
{ variant: 'solid', color: 'primary', className: 'bg-primary text-primary-text' },
|
||||
{ variant: 'solid', color: 'primary-negative', className: 'bg-primary-text text-primary' },
|
||||
{ variant: 'outline', color: 'primary', className: 'bg-transparent border-2 text-primary border-primary hover:bg-primary/10' },
|
||||
{ variant: 'outline', color: 'primary-negative', className: 'bg-transparent border-2 text-primary-text border-primary-text hover:bg-primary-text/10' },
|
||||
{ variant: 'ghost', color: 'primary', className: 'bg-transparent border-transparent text-primary hover:bg-primary/10' },
|
||||
{ variant: 'ghost', color: 'primary-negative', className: 'bg-transparent border-transparent text-primary-text hover:bg-primary-text/10' },
|
||||
{ variant: 'link', color: 'primary', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-primary' },
|
||||
{ variant: 'link', color: 'primary-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-primary-text' },
|
||||
|
||||
// Secondary
|
||||
{ variant: 'solid', color: 'secondary', className: 'bg-secondary text-secondary-text' },
|
||||
{ variant: 'solid', color: 'secondary-negative', className: 'bg-secondary-text text-secondary' },
|
||||
{ variant: 'outline', color: 'secondary', className: 'bg-transparent border-2 text-secondary border-secondary hover:bg-secondary/10' },
|
||||
{ variant: 'outline', color: 'secondary-negative', className: 'bg-transparent border-2 text-secondary-text border-secondary-text hover:bg-secondary-text/10' },
|
||||
{ variant: 'ghost', color: 'secondary', className: 'bg-transparent border-transparent text-secondary hover:bg-secondary/10' },
|
||||
{ variant: 'ghost', color: 'secondary-negative', className: 'bg-transparent border-transparent text-secondary-text hover:bg-secondary-text/10' },
|
||||
{ variant: 'link', color: 'secondary', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-secondary' },
|
||||
{ variant: 'link', color: 'secondary-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-secondary-text' },
|
||||
|
||||
// Tertiary
|
||||
{ variant: 'solid', color: 'tertiary', className: 'bg-tertiary text-tertiary-text' },
|
||||
{ variant: 'solid', color: 'tertiary-negative', className: 'bg-tertiary-text text-tertiary' },
|
||||
{ variant: 'outline', color: 'tertiary', className: 'bg-transparent border-2 text-tertiary border-tertiary hover:bg-tertiary/10' },
|
||||
{ variant: 'outline', color: 'tertiary-negative', className: 'bg-transparent border-2 text-tertiary-text border-tertiary-text hover:bg-tertiary-text/10' },
|
||||
{ variant: 'ghost', color: 'tertiary', className: 'bg-transparent border-transparent text-tertiary hover:bg-tertiary/10' },
|
||||
{ variant: 'ghost', color: 'tertiary-negative', className: 'bg-transparent border-transparent text-tertiary-text hover:bg-tertiary-text/10' },
|
||||
{ variant: 'link', color: 'tertiary', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-tertiary' },
|
||||
{ variant: 'link', color: 'tertiary-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-tertiary-text' },
|
||||
|
||||
// Error
|
||||
{ variant: 'solid', color: 'error', className: 'bg-error text-error-text' },
|
||||
{ variant: 'solid', color: 'error-negative', className: 'bg-error-text text-error' },
|
||||
{ variant: 'outline', color: 'error', className: 'bg-transparent border-2 text-error border-error hover:bg-error/10' },
|
||||
{ variant: 'outline', color: 'error-negative', className: 'bg-transparent border-2 text-error-text border-error-text hover:bg-error-text/10' },
|
||||
{ variant: 'ghost', color: 'error', className: 'bg-transparent border-transparent text-error hover:bg-error/10' },
|
||||
{ variant: 'ghost', color: 'error-negative', className: 'bg-transparent border-transparent text-error-text hover:bg-error-text/10' },
|
||||
{ variant: 'link', color: 'error', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-error' },
|
||||
{ variant: 'link', color: 'error-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-error-text' },
|
||||
|
||||
// Neutral
|
||||
{ variant: 'solid', color: 'neutral', className: 'bg-muted text-[var(--rb-text)]' },
|
||||
{ variant: 'solid', color: 'neutral-negative', className: 'bg-[var(--rb-text)] text-muted' },
|
||||
{ variant: 'outline', color: 'neutral', className: 'bg-transparent border-2 text-muted border-muted hover:bg-muted/10' },
|
||||
{ variant: 'outline', color: 'neutral-negative', className: 'bg-transparent border-2 text-[var(--rb-text)] border-[var(--rb-text)] hover:bg-[var(--rb-text)]/10' },
|
||||
{ variant: 'ghost', color: 'neutral', className: 'bg-transparent border-transparent text-muted hover:bg-muted/10' },
|
||||
{ variant: 'ghost', color: 'neutral-negative', className: 'bg-transparent border-transparent text-[var(--rb-text)] hover:bg-[var(--rb-text)]/10' },
|
||||
{ variant: 'link', color: 'neutral', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-muted' },
|
||||
{ variant: 'link', color: 'neutral-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-[var(--rb-text)]' },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
color: 'primary',
|
||||
size: 'm',
|
||||
rounded: 'md',
|
||||
fullWidth: false,
|
||||
isDisabled: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A highly extensible, polymorphic button component.
|
||||
*
|
||||
@@ -95,7 +199,7 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
|
||||
rounded = 'md',
|
||||
className,
|
||||
'data-testid': testId,
|
||||
isDisabled,
|
||||
isDisabled = false,
|
||||
href,
|
||||
} = props;
|
||||
|
||||
@@ -124,62 +228,14 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
|
||||
const { hoverProps, isHovered } = useHover({ isDisabled });
|
||||
|
||||
// Styles mapping
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
s: 'h-8 px-3 text-sm gap-1.5',
|
||||
m: 'h-10 px-4 text-base gap-2',
|
||||
l: 'h-12 px-6 text-lg gap-3',
|
||||
};
|
||||
|
||||
const iconClasses: Record<ButtonSize, string> = {
|
||||
s: 'w-4 h-4',
|
||||
m: 'w-5 h-5',
|
||||
l: 'w-6 h-6',
|
||||
};
|
||||
|
||||
const roundedClasses: Record<string, string> = {
|
||||
none: 'rounded-none',
|
||||
md: 'rounded-md',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
const getVariantClasses = (): string => {
|
||||
const isNegative = color.endsWith('-negative');
|
||||
const baseColor = color.replace('-negative', '');
|
||||
|
||||
const colors: Record<string, { base: string; text: string }> = {
|
||||
primary: { base: 'var(--rb-primary-base)', text: 'var(--rb-primary-text)' },
|
||||
secondary: { base: 'var(--rb-secondary-base)', text: 'var(--rb-secondary-text)' },
|
||||
tertiary: { base: 'var(--rb-tertiary-base)', text: 'var(--rb-tertiary-text)' },
|
||||
error: { base: 'var(--rb-error-base)', text: 'var(--rb-error-text)' },
|
||||
neutral: { base: 'var(--rb-muted-base)', text: 'var(--rb-text)' },
|
||||
};
|
||||
|
||||
const colorValues = colors[baseColor];
|
||||
const backgroundColor = isNegative ? colorValues.text : colorValues.base;
|
||||
const foregroundColor = isNegative ? colorValues.base : colorValues.text;
|
||||
|
||||
switch (variant) {
|
||||
case 'solid':
|
||||
return `bg-[${backgroundColor}] text-[${foregroundColor}] border-transparent`;
|
||||
case 'outline':
|
||||
return `bg-transparent text-[${backgroundColor}] border-[${backgroundColor}] border-2`;
|
||||
case 'ghost':
|
||||
return `bg-transparent text-[${backgroundColor}] border-transparent hover:bg-[${backgroundColor}]/10`;
|
||||
case 'link':
|
||||
return `bg-transparent text-[${backgroundColor}] border-transparent underline-offset-4 hover:underline px-0 h-auto`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const sharedClasses = cn(
|
||||
'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden cursor-pointer',
|
||||
'active:scale-[0.98]',
|
||||
sizeClasses[size],
|
||||
roundedClasses[rounded],
|
||||
getVariantClasses(),
|
||||
fullWidth && 'w-full',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed grayscale-[0.5] active:scale-100',
|
||||
buttonVariants({ variant, color, size, rounded, fullWidth, isDisabled }),
|
||||
isFocusVisible && 'ring-2 ring-[var(--rb-ring)] ring-offset-2',
|
||||
isHovered && !isDisabled && 'brightness-90 dark:brightness-110',
|
||||
className
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePress } from 'react-aria';
|
||||
|
||||
@@ -10,6 +11,45 @@ import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
export type { CodeLanguage };
|
||||
|
||||
const highlightedVariants = cva('block min-w-full font-mono text-sm [&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0', {
|
||||
variants: {
|
||||
showLineNumbers: {
|
||||
true: '[&>pre>code]:[counter-reset:line] [&>pre>code>.line]:relative [&>pre>code>.line]:pl-10 [&>pre>code>.line]:[counter-increment:line] [&>pre>code>.line]:before:content-[counter(line)] [&>pre>code>.line]:before:absolute [&>pre>code>.line]:before:left-0 [&>pre>code>.line]:before:top-0 [&>pre>code>.line]:before:inline-block [&>pre>code>.line]:before:w-8 [&>pre>code>.line]:before:text-right [&>pre>code>.line]:before:text-[var(--rb-muted-text)]/50 [&>pre>code>.line]:before:select-none [&>pre>code>.line]:before:border-r [&>pre>code>.line]:before:border-[var(--rb-outline)]/10 [&>pre>code>.line]:before:pr-3 [&>pre>code>.line]:before:font-mono [&>pre>code>.line]:before:text-[10px]',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
showLineNumbers: false,
|
||||
},
|
||||
});
|
||||
|
||||
const blockVariants = cva('block min-w-full font-mono whitespace-pre', {
|
||||
variants: {
|
||||
showLineNumbers: {
|
||||
true: '[counter-reset:line] [&>span]:relative [&>span]:pl-10 [&>span]:[counter-increment:line] [&>span]:before:content-[counter(line)] [&>span]:before:absolute [&>span]:before:left-0 [&>span]:before:top-[4px] [&>span]:before:inline-block [&>span]:before:w-8 [&>span]:before:text-right [&>span]:before:text-[var(--rb-muted-text)]/50 [&>span]:before:select-none [&>span]:before:border-r [&>span]:before:border-[var(--rb-outline)]/10 [&>span]:before:pr-3 [&>span]:before:font-mono [&>span]:before:text-[10px]',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
showLineNumbers: false,
|
||||
},
|
||||
});
|
||||
|
||||
const inlineVariants = cva(
|
||||
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono text-[var(--rb-color-red-600)] dark:text-[var(--rb-color-red-400)] cursor-pointer transition-all duration-200 hover:text-[var(--rb-color-red-500)] hover:bg-[var(--rb-color-red-500)]/10 active:scale-95',
|
||||
{
|
||||
variants: {
|
||||
isCopied: {
|
||||
true: 'bg-[var(--rb-color-red-500)]/20 ring-1 ring-[var(--rb-color-red-500)]/40',
|
||||
false: 'bg-transparent ring-1 ring-transparent',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isCopied: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for the Code component.
|
||||
*/
|
||||
@@ -116,27 +156,7 @@ export const Code = memo(function Code({
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
ref={divRef}
|
||||
className={cn(
|
||||
'block min-w-full font-mono text-sm',
|
||||
'[&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0',
|
||||
showLineNumbers && [
|
||||
'[&>pre>code]:[counter-reset:line]',
|
||||
'[&>pre>code>.line]:relative [&>pre>code>.line]:pl-10',
|
||||
'[&>pre>code>.line]:[counter-increment:line]',
|
||||
'[&>pre>code>.line]:before:content-[counter(line)]',
|
||||
'[&>pre>code>.line]:before:absolute [&>pre>code>.line]:before:left-0 [&>pre>code>.line]:before:top-0',
|
||||
'[&>pre>code>.line]:before:inline-block',
|
||||
'[&>pre>code>.line]:before:w-8',
|
||||
'[&>pre>code>.line]:before:text-right',
|
||||
'[&>pre>code>.line]:before:text-[var(--rb-muted-text)]/50',
|
||||
'[&>pre>code>.line]:before:select-none',
|
||||
'[&>pre>code>.line]:before:border-r',
|
||||
'[&>pre>code>.line]:before:border-[var(--rb-outline)]/10',
|
||||
'[&>pre>code>.line]:before:pr-3',
|
||||
'[&>pre>code>.line]:before:font-mono [&>pre>code>.line]:before:text-[10px]',
|
||||
],
|
||||
className
|
||||
)}
|
||||
className={cn(highlightedVariants({ showLineNumbers }), className)}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
@@ -157,26 +177,7 @@ export const Code = memo(function Code({
|
||||
return (
|
||||
<code
|
||||
ref={codeRef}
|
||||
className={cn(
|
||||
'block min-w-full font-mono whitespace-pre',
|
||||
showLineNumbers && [
|
||||
'[counter-reset:line]',
|
||||
'[&>span]:relative [&>span]:pl-10',
|
||||
'[&>span]:[counter-increment:line]',
|
||||
'[&>span]:before:content-[counter(line)]',
|
||||
'[&>span]:before:absolute [&>span]:before:left-0 [&>span]:before:top-[4px]',
|
||||
'[&>span]:before:inline-block',
|
||||
'[&>span]:before:w-8',
|
||||
'[&>span]:before:text-right',
|
||||
'[&>span]:before:text-[var(--rb-muted-text)]/50',
|
||||
'[&>span]:before:select-none',
|
||||
'[&>span]:before:border-r',
|
||||
'[&>span]:before:border-[var(--rb-outline)]/10',
|
||||
'[&>span]:before:pr-3',
|
||||
'[&>span]:before:font-mono [&>span]:before:text-[10px]',
|
||||
],
|
||||
className
|
||||
)}
|
||||
className={cn(blockVariants({ showLineNumbers }), className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{content}
|
||||
@@ -189,17 +190,7 @@ export const Code = memo(function Code({
|
||||
{...pressProps}
|
||||
ref={codeRef}
|
||||
aria-label="Copy code snippet"
|
||||
className={cn(
|
||||
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono',
|
||||
'text-[var(--rb-color-red-600)] dark:text-[var(--rb-color-red-400)]',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'hover:text-[var(--rb-color-red-500)] hover:bg-[var(--rb-color-red-500)]/10',
|
||||
'active:scale-95',
|
||||
isCopied
|
||||
? 'bg-[var(--rb-color-red-500)]/20 ring-1 ring-[var(--rb-color-red-500)]/40'
|
||||
: 'bg-transparent ring-1 ring-transparent',
|
||||
className
|
||||
)}
|
||||
className={cn(inlineVariants({ isCopied }), className)}
|
||||
data-testid={testId}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||
|
||||
@@ -9,6 +10,36 @@ import { CodeContext, type CodeContextValue } from '../contexts/CodeContext/Code
|
||||
import type { CodeLanguage } from '../lib/highlighter/highlighter';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
const copyButtonVariants = cva(
|
||||
'h-7 w-7 p-0 bg-[var(--rb-container-base)]/80 backdrop-blur-sm transition-opacity',
|
||||
{
|
||||
variants: {
|
||||
isCopied: {
|
||||
true: '',
|
||||
false: 'opacity-0 group-hover:opacity-100 focus:opacity-100',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
isCopied: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const preVariants = cva(
|
||||
'overflow-auto p-4 text-sm font-mono leading-relaxed select-text whitespace-pre',
|
||||
{
|
||||
variants: {
|
||||
hasLabel: {
|
||||
true: 'pt-8',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
hasLabel: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for the CodeBlock component.
|
||||
*/
|
||||
@@ -122,10 +153,7 @@ export const CodeBlock = memo(function CodeBlock({
|
||||
<div className="absolute top-2 right-2 pointer-events-auto">
|
||||
<Button
|
||||
aria-label="Copy code"
|
||||
className={cn(
|
||||
'h-7 w-7 p-0 bg-[var(--rb-container-base)]/80 backdrop-blur-sm transition-opacity',
|
||||
!isCopied && 'opacity-0 group-hover:opacity-100 focus:opacity-100'
|
||||
)}
|
||||
className={cn(copyButtonVariants({ isCopied }))}
|
||||
color="neutral"
|
||||
size="s"
|
||||
variant="ghost"
|
||||
@@ -143,10 +171,7 @@ export const CodeBlock = memo(function CodeBlock({
|
||||
{/* Code Area */}
|
||||
<pre
|
||||
ref={preRef}
|
||||
className={cn(
|
||||
'overflow-auto p-4 text-sm font-mono leading-relaxed select-text whitespace-pre',
|
||||
label && 'pt-8'
|
||||
)}
|
||||
className={cn(preVariants({ hasLabel: !!label }))}
|
||||
style={{ maxHeight: maxHeight }}
|
||||
>
|
||||
<CodeContext.Provider value={contextValue}>{children}</CodeContext.Provider>
|
||||
|
||||
@@ -1,49 +1,12 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { memo } from 'react';
|
||||
import { usePress } from 'react-aria';
|
||||
|
||||
import type { SpacingScale } from '../../lib/scale';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
import type { PressEvents } from 'react-aria';
|
||||
|
||||
/**
|
||||
* Tailwind gap spacing scale values.
|
||||
*/
|
||||
type GapScale =
|
||||
| 0
|
||||
| 0.5
|
||||
| 1
|
||||
| 1.5
|
||||
| 2
|
||||
| 2.5
|
||||
| 3
|
||||
| 3.5
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
| 7
|
||||
| 8
|
||||
| 9
|
||||
| 10
|
||||
| 11
|
||||
| 12
|
||||
| 14
|
||||
| 16
|
||||
| 20
|
||||
| 24
|
||||
| 28
|
||||
| 32
|
||||
| 36
|
||||
| 40
|
||||
| 44
|
||||
| 48
|
||||
| 52
|
||||
| 56
|
||||
| 60
|
||||
| 64
|
||||
| 72
|
||||
| 80
|
||||
| 96;
|
||||
|
||||
/**
|
||||
* Background variants for the Container.
|
||||
*/
|
||||
@@ -75,7 +38,7 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
|
||||
/** Background color variant */
|
||||
bg?: ContainerBackground;
|
||||
/** Padding using Tailwind spacing scale */
|
||||
padding?: GapScale;
|
||||
padding?: SpacingScale;
|
||||
/** Maximum width constraint */
|
||||
maxWidth?: ContainerMaxWidth;
|
||||
/** Border radius variant */
|
||||
@@ -98,6 +61,91 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
const containerVariants = cva('overflow-hidden', {
|
||||
variants: {
|
||||
bg: {
|
||||
base: 'bg-[var(--rb-container-base)] text-[var(--rb-container-text)]',
|
||||
primary: 'bg-[var(--rb-primary-base)] text-[var(--rb-primary-text)]',
|
||||
secondary: 'bg-[var(--rb-secondary-base)] text-[var(--rb-secondary-text)]',
|
||||
tertiary: 'bg-[var(--rb-tertiary-base)] text-[var(--rb-tertiary-text)]',
|
||||
error: 'bg-[var(--rb-error-base)] text-[var(--rb-error-text)]',
|
||||
muted: 'bg-[var(--rb-muted-base)] text-[var(--rb-muted-text)]',
|
||||
transparent: 'bg-transparent',
|
||||
},
|
||||
maxWidth: {
|
||||
sm: 'max-w-screen-sm',
|
||||
md: 'max-w-screen-md',
|
||||
lg: 'max-w-screen-lg',
|
||||
xl: 'max-w-screen-xl',
|
||||
none: 'max-w-none',
|
||||
},
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
padding: {
|
||||
0: 'p-0',
|
||||
0.5: 'p-0.5',
|
||||
1: 'p-1',
|
||||
1.5: 'p-1.5',
|
||||
2: 'p-2',
|
||||
2.5: 'p-2.5',
|
||||
3: 'p-3',
|
||||
3.5: 'p-3.5',
|
||||
4: 'p-4',
|
||||
5: 'p-5',
|
||||
6: 'p-6',
|
||||
7: 'p-7',
|
||||
8: 'p-8',
|
||||
9: 'p-9',
|
||||
10: 'p-10',
|
||||
11: 'p-11',
|
||||
12: 'p-12',
|
||||
14: 'p-14',
|
||||
16: 'p-16',
|
||||
20: 'p-20',
|
||||
24: 'p-24',
|
||||
28: 'p-28',
|
||||
32: 'p-32',
|
||||
36: 'p-36',
|
||||
40: 'p-40',
|
||||
44: 'p-44',
|
||||
48: 'p-48',
|
||||
52: 'p-52',
|
||||
56: 'p-56',
|
||||
60: 'p-60',
|
||||
64: 'p-64',
|
||||
72: 'p-72',
|
||||
80: 'p-80',
|
||||
96: 'p-96',
|
||||
},
|
||||
shadow: {
|
||||
true: 'shadow-md',
|
||||
false: '',
|
||||
},
|
||||
border: {
|
||||
true: 'border border-[var(--rb-outline)]',
|
||||
false: '',
|
||||
},
|
||||
interactive: {
|
||||
true: 'cursor-pointer transition-all duration-200 hover:brightness-95 active:opacity-80',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
bg: 'base',
|
||||
maxWidth: 'none',
|
||||
rounded: 'none',
|
||||
padding: 0,
|
||||
shadow: false,
|
||||
border: false,
|
||||
interactive: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A generic surface component for creating structural layouts.
|
||||
*
|
||||
@@ -134,83 +182,20 @@ export const Container = memo(function Container({
|
||||
const interactionProps = isPressable ? pressProps : {};
|
||||
const FinalComponent = isLink ? 'a' : Component;
|
||||
|
||||
const bgClasses: Record<ContainerBackground, string> = {
|
||||
base: 'bg-[var(--rb-container-base)] text-[var(--rb-container-text)]',
|
||||
primary: 'bg-[var(--rb-primary-base)] text-[var(--rb-primary-text)]',
|
||||
secondary: 'bg-[var(--rb-secondary-base)] text-[var(--rb-secondary-text)]',
|
||||
tertiary: 'bg-[var(--rb-tertiary-base)] text-[var(--rb-tertiary-text)]',
|
||||
error: 'bg-[var(--rb-error-base)] text-[var(--rb-error-text)]',
|
||||
muted: 'bg-[var(--rb-muted-base)] text-[var(--rb-muted-text)]',
|
||||
transparent: 'bg-transparent',
|
||||
};
|
||||
|
||||
const maxWidthClasses: Record<ContainerMaxWidth, string> = {
|
||||
sm: 'max-w-screen-sm',
|
||||
md: 'max-w-screen-md',
|
||||
lg: 'max-w-screen-lg',
|
||||
xl: 'max-w-screen-xl',
|
||||
none: 'max-w-none',
|
||||
};
|
||||
|
||||
const roundedClasses: Record<ContainerRounded, string> = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
const paddingClasses: Record<GapScale, string> = {
|
||||
0: 'p-0',
|
||||
0.5: 'p-0.5',
|
||||
1: 'p-1',
|
||||
1.5: 'p-1.5',
|
||||
2: 'p-2',
|
||||
2.5: 'p-2.5',
|
||||
3: 'p-3',
|
||||
3.5: 'p-3.5',
|
||||
4: 'p-4',
|
||||
5: 'p-5',
|
||||
6: 'p-6',
|
||||
7: 'p-7',
|
||||
8: 'p-8',
|
||||
9: 'p-9',
|
||||
10: 'p-10',
|
||||
11: 'p-11',
|
||||
12: 'p-12',
|
||||
14: 'p-14',
|
||||
16: 'p-16',
|
||||
20: 'p-20',
|
||||
24: 'p-24',
|
||||
28: 'p-28',
|
||||
32: 'p-32',
|
||||
36: 'p-36',
|
||||
40: 'p-40',
|
||||
44: 'p-44',
|
||||
48: 'p-48',
|
||||
52: 'p-52',
|
||||
56: 'p-56',
|
||||
60: 'p-60',
|
||||
64: 'p-64',
|
||||
72: 'p-72',
|
||||
80: 'p-80',
|
||||
96: 'p-96',
|
||||
};
|
||||
|
||||
return (
|
||||
<FinalComponent
|
||||
{...interactionProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden',
|
||||
bgClasses[bg],
|
||||
maxWidthClasses[maxWidth],
|
||||
roundedClasses[rounded],
|
||||
paddingClasses[padding],
|
||||
shadow && 'shadow-md',
|
||||
border && 'border border-[var(--rb-outline)]',
|
||||
(isPressable || isLink) &&
|
||||
'cursor-pointer transition-all duration-200 hover:brightness-95 active:opacity-80',
|
||||
containerVariants({
|
||||
bg,
|
||||
maxWidth,
|
||||
rounded,
|
||||
padding,
|
||||
shadow,
|
||||
border,
|
||||
interactive: isPressable || isLink,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Stub } from '../Stub/Stub';
|
||||
@@ -38,6 +39,41 @@ interface ImageBaseProps {
|
||||
*/
|
||||
export type ImageProps = ImageBaseProps & ImageDimensions;
|
||||
|
||||
const imageWrapperVariants = cva('relative overflow-hidden', {
|
||||
variants: {
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
shadow: {
|
||||
none: '',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
rounded: 'none',
|
||||
shadow: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
const imageVariants = cva('w-full h-full transition-opacity duration-300', {
|
||||
variants: {
|
||||
objectFit: {
|
||||
cover: 'object-cover',
|
||||
contain: 'object-contain',
|
||||
fill: 'object-fill',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
objectFit: 'cover',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A performance-optimized Image component with built-in CLS prevention,
|
||||
* loading states, and Rainby design system styles.
|
||||
@@ -52,21 +88,6 @@ export const Image: React.FC<ImageProps> = (props) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
const shadowClasses = {
|
||||
none: '',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
};
|
||||
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
width: 'width' in props ? props.width : undefined,
|
||||
height: 'height' in props ? props.height : undefined,
|
||||
@@ -75,12 +96,7 @@ export const Image: React.FC<ImageProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden',
|
||||
roundedClasses[rounded],
|
||||
shadowClasses[shadow],
|
||||
className
|
||||
)}
|
||||
className={cn(imageWrapperVariants({ rounded, shadow }), className)}
|
||||
data-testid="image-wrapper"
|
||||
style={wrapperStyle}
|
||||
>
|
||||
@@ -89,13 +105,7 @@ export const Image: React.FC<ImageProps> = (props) => {
|
||||
)}
|
||||
<img
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'w-full h-full transition-opacity duration-300',
|
||||
objectFit === 'cover' && 'object-cover',
|
||||
objectFit === 'contain' && 'object-contain',
|
||||
objectFit === 'fill' && 'object-fill',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
className={cn(imageVariants({ objectFit }), isLoaded ? 'opacity-100' : 'opacity-0')}
|
||||
data-testid="image-element"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
@@ -29,18 +30,32 @@ export interface ListProps extends ClassNameProps, TestIdProps, React.HTMLAttrib
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
const spacingStyles: Record<ListSpacing, string> = {
|
||||
none: 'gap-0',
|
||||
s: 'gap-1',
|
||||
m: 'gap-2',
|
||||
l: 'gap-4',
|
||||
};
|
||||
|
||||
const variantStyles: Record<ListVariant, string> = {
|
||||
unordered: 'list-disc pl-5',
|
||||
ordered: 'list-decimal pl-5',
|
||||
plain: 'list-none pl-0',
|
||||
};
|
||||
const listVariants = cva('m-0 flex flex-col', {
|
||||
variants: {
|
||||
variant: {
|
||||
unordered: 'list-disc pl-5',
|
||||
ordered: 'list-decimal pl-5',
|
||||
plain: 'list-none pl-0',
|
||||
},
|
||||
spacing: {
|
||||
none: 'gap-0',
|
||||
s: 'gap-1',
|
||||
m: 'gap-2',
|
||||
l: 'gap-4',
|
||||
},
|
||||
// Hide default markers if a global custom marker is provided.
|
||||
hasCustomMarker: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [{ hasCustomMarker: true, className: 'list-none pl-0' }],
|
||||
defaultVariants: {
|
||||
variant: 'unordered',
|
||||
spacing: 'none',
|
||||
hasCustomMarker: false,
|
||||
},
|
||||
});
|
||||
|
||||
const ListContext = React.createContext<{ marker?: React.ReactNode }>({});
|
||||
|
||||
@@ -65,13 +80,7 @@ const ListComponent = forwardRef<HTMLElement, ListProps>(function List(
|
||||
<Component
|
||||
{...props}
|
||||
ref={castRef<HTMLElement>(ref)}
|
||||
className={cn(
|
||||
'm-0 flex flex-col',
|
||||
// Hide default markers if a global custom marker is provided
|
||||
hasCustomMarker ? 'list-none pl-0' : variantStyles[variant],
|
||||
spacingStyles[spacing],
|
||||
className
|
||||
)}
|
||||
className={cn(listVariants({ variant, spacing, hasCustomMarker }), className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,23 @@ export interface PageColumnProps {
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
const pageColumnVariants = cva('w-full transition-all duration-200', {
|
||||
variants: {
|
||||
size: {
|
||||
small: 'lg:w-[var(--rb-page-column-small,300px)] lg:flex-none',
|
||||
full: 'flex-1',
|
||||
},
|
||||
sticky: {
|
||||
true: 'lg:sticky lg:top-24 self-start',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'full',
|
||||
sticky: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
/**
|
||||
* A single column unit within the PageColumns host.
|
||||
@@ -49,18 +67,6 @@ export const PageColumn = ({
|
||||
as: Component = 'div',
|
||||
}: PageColumnProps) => {
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
'w-full transition-all duration-200',
|
||||
{
|
||||
'lg:w-[var(--rb-page-column-small,300px)] lg:flex-none': size === 'small',
|
||||
'flex-1': size === 'full',
|
||||
'lg:sticky lg:top-24 self-start': sticky,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
<Component className={cn(pageColumnVariants({ size, sticky }), className)}>{children}</Component>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,8 @@ export interface PageColumnsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const pageColumnsVariants = cva('flex flex-col lg:flex-row w-full');
|
||||
|
||||
/**
|
||||
/**
|
||||
* Host component for a responsive column system.
|
||||
@@ -34,18 +37,12 @@ export interface PageColumnsProps {
|
||||
* ```
|
||||
*/
|
||||
export const PageColumns = ({ children, gap = 6, className }: PageColumnsProps) => {
|
||||
// `gap` accepts either a Tailwind spacing-scale number (`gap-N`) or an
|
||||
// arbitrary class string, so the value space is not enumerable as a cva
|
||||
// variant. Compute the numeric class here and pass strings through verbatim.
|
||||
const gapClass = typeof gap === 'number' ? `gap-${gap}` : gap;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col lg:flex-row w-full',
|
||||
{
|
||||
[`gap-${gap}`]: typeof gap === 'number',
|
||||
[String(gap)]: typeof gap === 'string',
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className={cn(pageColumnsVariants(), gapClass, className)}>{children}</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
@@ -57,6 +58,39 @@ export interface PageContainerProps {
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
const pageContainerVariants = cva('w-full mx-auto', {
|
||||
variants: {
|
||||
width: {
|
||||
slim: 'max-w-[var(--rb-page-width-slim,1100px)]',
|
||||
default: 'max-w-[var(--rb-page-width-default,1600px)]',
|
||||
wide: 'max-w-[var(--rb-page-width-wide,1920px)]',
|
||||
full: 'max-w-full',
|
||||
},
|
||||
paddingX: {
|
||||
none: 'px-0',
|
||||
small: 'px-2 sm:px-4',
|
||||
medium: 'px-4 sm:px-6 lg:px-8',
|
||||
large: 'px-6 sm:px-8 lg:px-12',
|
||||
},
|
||||
paddingY: {
|
||||
none: 'py-0',
|
||||
small: 'py-4 sm:py-6',
|
||||
medium: 'py-8 sm:py-12',
|
||||
large: 'py-12 sm:py-24',
|
||||
},
|
||||
centerVertically: {
|
||||
true: 'flex-1 flex flex-col justify-center',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
width: 'default',
|
||||
paddingX: 'medium',
|
||||
paddingY: 'none',
|
||||
centerVertically: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
/**
|
||||
* Manages the horizontal and vertical constraints for page content.
|
||||
@@ -79,37 +113,10 @@ export const PageContainer = ({
|
||||
className,
|
||||
as: Component = 'div',
|
||||
}: PageContainerProps) => {
|
||||
const widthClasses = {
|
||||
slim: 'max-w-[var(--rb-page-width-slim,1100px)]',
|
||||
default: 'max-w-[var(--rb-page-width-default,1600px)]',
|
||||
wide: 'max-w-[var(--rb-page-width-wide,1920px)]',
|
||||
full: 'max-w-full',
|
||||
};
|
||||
|
||||
const pxClasses = {
|
||||
none: 'px-0',
|
||||
small: 'px-2 sm:px-4',
|
||||
medium: 'px-4 sm:px-6 lg:px-8',
|
||||
large: 'px-6 sm:px-8 lg:px-12',
|
||||
};
|
||||
|
||||
const pyClasses = {
|
||||
none: 'py-0',
|
||||
small: 'py-4 sm:py-6',
|
||||
medium: 'py-8 sm:py-12',
|
||||
large: 'py-12 sm:py-24',
|
||||
};
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
'w-full mx-auto',
|
||||
widthClasses[width],
|
||||
pxClasses[paddingX],
|
||||
pyClasses[paddingY],
|
||||
{
|
||||
'flex-1 flex flex-col justify-center': centerVertically,
|
||||
},
|
||||
pageContainerVariants({ width, paddingX, paddingY, centerVertically }),
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -35,4 +35,18 @@ describe('PageHead', () => {
|
||||
expect(header).toHaveClass('fixed');
|
||||
expect(header).not.toHaveClass('sticky');
|
||||
});
|
||||
|
||||
it('reserves flow space below the out-of-flow bar for always-collapsed strategies', () => {
|
||||
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
|
||||
const spacer = screen.getByRole('banner').nextElementSibling;
|
||||
expect(spacer).not.toBeNull();
|
||||
// The spacer is an invisible copy of the bar so it reserves the exact height.
|
||||
expect(spacer).toHaveClass('invisible');
|
||||
expect(spacer).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('does not add a spacer for in-flow strategies', () => {
|
||||
render(<PageHead heading="Test" strategy="collapsible-sticky" />);
|
||||
expect(screen.getByRole('banner').nextElementSibling).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, useScroll } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import { Header } from '../../Header/Header';
|
||||
@@ -42,6 +43,27 @@ export interface PageHeadProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Root positioning is fully determined by `strategy`:
|
||||
// - `collapsible-sticky` stays in-flow so expanded content scrolls away.
|
||||
// - `*-sticky` / `*-fixed` strategies pin the root to the top.
|
||||
const pageHeadRootVariants = cva(
|
||||
'page-head w-full bg-[var(--rb-background)] border-b border-[var(--rb-outline)]/10',
|
||||
{
|
||||
variants: {
|
||||
strategy: {
|
||||
'always-collapsed-fixed': 'fixed top-0 z-10',
|
||||
'always-collapsed-sticky': 'sticky top-0 z-10',
|
||||
'always-expanded-fixed': 'fixed top-0 z-10',
|
||||
'always-expanded-sticky': 'sticky top-0 z-10',
|
||||
'collapsible-sticky': '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
strategy: 'collapsible-sticky',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A universal page head component with multiple layout and behavior strategies.
|
||||
*
|
||||
@@ -80,18 +102,7 @@ export const PageHead = ({
|
||||
}
|
||||
}, [scrollY, isCollapsible]);
|
||||
|
||||
const rootClasses = cn(
|
||||
'page-head w-full bg-[var(--rb-background)] border-b border-[var(--rb-outline)]/10',
|
||||
// Always-collapsed or always-expanded strategies maintain their sticky/fixed behavior.
|
||||
// Collapsible-sticky strategy keeps the root in-flow so expanded content scrolls away.
|
||||
!isCollapsible &&
|
||||
(strategy.includes('sticky')
|
||||
? 'sticky top-0 z-10'
|
||||
: strategy.includes('fixed')
|
||||
? 'fixed top-0 z-10'
|
||||
: ''),
|
||||
className
|
||||
);
|
||||
const rootClasses = cn(pageHeadRootVariants({ strategy }), className);
|
||||
|
||||
const expandedContent = (
|
||||
<PageContainer paddingY="medium">
|
||||
@@ -119,44 +130,68 @@ export const PageHead = ({
|
||||
|
||||
const collapsedContent = (
|
||||
<PageContainer paddingY="none">
|
||||
<HStack align="center" className="h-14" justify="between">
|
||||
<HStack align="center" className="min-w-0 flex-1" gap={4}>
|
||||
{addonLeft && <div className="flex-shrink-0">{addonLeft}</div>}
|
||||
<Header level={4} overflow="ellipsis">
|
||||
{/*
|
||||
* A single row on wide screens (title left, menu right). When the title
|
||||
* and menu can't fit together, the row wraps the menu onto its own line
|
||||
* beneath the title instead of overlapping. `whitespace-nowrap` keeps the
|
||||
* title a single unit so it's the menu that wraps, not the title that
|
||||
* shrinks. The invisible spacer copy below reserves whichever height this
|
||||
* ends up being, so page content always starts clear of the bar.
|
||||
*/}
|
||||
<HStack align="center" className="py-2.5" gap={4} justify="between" wrap="wrap">
|
||||
<HStack align="center" className="flex-1" gap={4}>
|
||||
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
|
||||
<Header className="min-w-0" level={4} overflow="ellipsis">
|
||||
{heading}
|
||||
</Header>
|
||||
</HStack>
|
||||
{addonRight && <div className="flex-shrink-0 ml-4">{addonRight}</div>}
|
||||
{addonRight && <div className="flex-shrink-0">{addonRight}</div>}
|
||||
</HStack>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
return (
|
||||
<header className={rootClasses}>
|
||||
{/* Expanded Content: In-flow for collapsible, or sticky/fixed for others */}
|
||||
{!isAlwaysCollapsed && (
|
||||
<>
|
||||
<header className={rootClasses}>
|
||||
{/* Expanded Content: In-flow for collapsible, or sticky/fixed for others */}
|
||||
{!isAlwaysCollapsed && (
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity duration-300',
|
||||
isCollapsible && isCollapsed ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{expandedContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed Content: Fixed overlay for collapsible, or absolute/relative within sticky/fixed root */}
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity duration-300',
|
||||
isCollapsible && isCollapsed ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
||||
'z-20 transition-all duration-300 transform bg-[var(--rb-background)]/90 backdrop-blur-md border-b border-[var(--rb-outline)]/10 shadow-md',
|
||||
isAlwaysCollapsed || (isCollapsible && isCollapsed)
|
||||
? 'translate-y-0 opacity-100'
|
||||
: '-translate-y-full opacity-0 pointer-events-none',
|
||||
isCollapsible ? 'fixed top-0 left-0 right-0' : 'absolute top-0 left-0 right-0'
|
||||
)}
|
||||
>
|
||||
{expandedContent}
|
||||
{collapsedContent}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
* Always-collapsed strategies show only the collapsed bar, which is taken
|
||||
* out of normal flow (the root is fixed, and its collapsed bar is absolute),
|
||||
* so the header reserves no height. Without this spacer, page content would
|
||||
* start at the top of the viewport, underneath the bar. Rendering an
|
||||
* invisible copy of the bar reserves exactly its height at every viewport —
|
||||
* including when it wraps to two rows — with no measurement or layout shift.
|
||||
*/}
|
||||
{isAlwaysCollapsed && (
|
||||
<div aria-hidden="true" className="invisible shrink-0">
|
||||
{collapsedContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed Content: Fixed overlay for collapsible, or absolute/relative within sticky/fixed root */}
|
||||
<div
|
||||
className={cn(
|
||||
'z-20 transition-all duration-300 transform bg-[var(--rb-background)]/90 backdrop-blur-md border-b border-[var(--rb-outline)]/10 shadow-md',
|
||||
isAlwaysCollapsed || (isCollapsible && isCollapsed)
|
||||
? 'translate-y-0 opacity-100'
|
||||
: '-translate-y-full opacity-0 pointer-events-none',
|
||||
isCollapsible ? 'fixed top-0 left-0 right-0' : 'absolute top-0 left-0 right-0'
|
||||
)}
|
||||
>
|
||||
{collapsedContent}
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { Text } from '../Text/Text';
|
||||
@@ -17,6 +18,28 @@ export interface ParagraphProps extends Omit<TextProps, 'as'> {
|
||||
dropCap?: boolean;
|
||||
}
|
||||
|
||||
const paragraphVariants = cva('', {
|
||||
variants: {
|
||||
gutterBottom: {
|
||||
true: 'mb-4',
|
||||
false: '',
|
||||
},
|
||||
indent: {
|
||||
true: 'indent-8',
|
||||
false: '',
|
||||
},
|
||||
dropCap: {
|
||||
true: 'first-letter:text-7xl first-letter:font-bold first-letter:float-left first-letter:mr-3 first-letter:leading-none',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
gutterBottom: false,
|
||||
indent: false,
|
||||
dropCap: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A semantic paragraph component that wraps the Text component.
|
||||
*
|
||||
@@ -32,13 +55,7 @@ const ParagraphComponent = forwardRef<HTMLElement, ParagraphProps>(function Para
|
||||
<Text
|
||||
ref={ref}
|
||||
as="p"
|
||||
className={cn(
|
||||
gutterBottom && 'mb-4',
|
||||
indent && 'indent-8',
|
||||
dropCap &&
|
||||
'first-letter:text-7xl first-letter:font-bold first-letter:float-left first-letter:mr-3 first-letter:leading-none',
|
||||
className
|
||||
)}
|
||||
className={cn(paragraphVariants({ gutterBottom, indent, dropCap }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Separator } from './Separator';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
|
||||
describe('Separator', () => {
|
||||
it('renders a single horizontal separator by default', () => {
|
||||
render(<Separator />);
|
||||
const separator = screen.getByRole('separator');
|
||||
expect(separator).toHaveAttribute('aria-orientation', 'horizontal');
|
||||
expect(separator).toHaveClass('border-t');
|
||||
});
|
||||
|
||||
it('renders a single vertical separator', () => {
|
||||
render(<Separator direction="vertical" />);
|
||||
const separator = screen.getByRole('separator');
|
||||
expect(separator).toHaveAttribute('aria-orientation', 'vertical');
|
||||
expect(separator).toHaveClass('border-l');
|
||||
});
|
||||
|
||||
it('applies an explicit color override', () => {
|
||||
render(<Separator color="black" />);
|
||||
expect(screen.getByRole('separator')).toHaveClass('border-foreground');
|
||||
});
|
||||
|
||||
it('applies the thinned gradient mask by default and omits it when disabled', () => {
|
||||
const { rerender } = render(<Separator data-testid="sep" />);
|
||||
expect(screen.getByTestId('sep').className).toContain('mask-image');
|
||||
|
||||
rerender(<Separator data-testid="sep" thinned="none" />);
|
||||
expect(screen.getByTestId('sep').className).not.toContain('mask-image');
|
||||
});
|
||||
|
||||
it('renders a double horizontal separator with two lines', () => {
|
||||
const { container } = render(<Separator type="double" />);
|
||||
const separator = screen.getByRole('separator');
|
||||
expect(separator).toHaveAttribute('aria-orientation', 'horizontal');
|
||||
expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders a double vertical separator laid out in a row', () => {
|
||||
const { container } = render(<Separator direction="vertical" type="double" />);
|
||||
const separator = screen.getByRole('separator');
|
||||
expect(separator).toHaveAttribute('aria-orientation', 'vertical');
|
||||
expect(separator).toHaveClass('flex-row');
|
||||
expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('forwards data-testid and merges a custom className', () => {
|
||||
render(<Separator className="my-custom-class" data-testid="sep" />);
|
||||
expect(screen.getByTestId('sep')).toHaveClass('my-custom-class');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { VStack } from '../VStack/VStack';
|
||||
@@ -30,21 +31,61 @@ export interface SeparatorProps extends ClassNameProps, TestIdProps {
|
||||
thinned?: SeparatorThinnedType;
|
||||
}
|
||||
|
||||
const THINNED_TYPE_CLASSES: Record<
|
||||
'horizontal' | 'vertical',
|
||||
Record<Exclude<SeparatorThinnedType, 'none'>, string>
|
||||
> = {
|
||||
horizontal: {
|
||||
thinned: '[mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)]',
|
||||
'thinned-start': '[mask-image:linear-gradient(to_right,transparent,black_20%,black)]',
|
||||
'thinned-end': '[mask-image:linear-gradient(to_right,black,black_80%,transparent)]',
|
||||
const separatorLineVariants = cva('border-0 border-solid', {
|
||||
variants: {
|
||||
color: {
|
||||
black: 'border-foreground',
|
||||
outline: 'border-[var(--rb-outline)]',
|
||||
'outline-light': 'border-[var(--rb-outline)]/40',
|
||||
},
|
||||
direction: {
|
||||
// 1px stroke for subtler appearance, plus size classes to fill the
|
||||
// container (the border provides the visual line).
|
||||
horizontal: 'border-t w-full h-0',
|
||||
vertical: 'border-l h-full w-0',
|
||||
},
|
||||
thinned: {
|
||||
thinned: '',
|
||||
'thinned-start': '',
|
||||
'thinned-end': '',
|
||||
none: '',
|
||||
},
|
||||
},
|
||||
vertical: {
|
||||
thinned: '[mask-image:linear-gradient(to_bottom,transparent,black_20%,black_80%,transparent)]',
|
||||
'thinned-start': '[mask-image:linear-gradient(to_bottom,transparent,black_20%,black)]',
|
||||
'thinned-end': '[mask-image:linear-gradient(to_bottom,black,black_80%,transparent)]',
|
||||
},
|
||||
};
|
||||
// Thinned effect using CSS mask; the gradient axis depends on direction.
|
||||
compoundVariants: [
|
||||
{
|
||||
direction: 'horizontal',
|
||||
thinned: 'thinned',
|
||||
className: '[mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)]',
|
||||
},
|
||||
{
|
||||
direction: 'horizontal',
|
||||
thinned: 'thinned-start',
|
||||
className: '[mask-image:linear-gradient(to_right,transparent,black_20%,black)]',
|
||||
},
|
||||
{
|
||||
direction: 'horizontal',
|
||||
thinned: 'thinned-end',
|
||||
className: '[mask-image:linear-gradient(to_right,black,black_80%,transparent)]',
|
||||
},
|
||||
{
|
||||
direction: 'vertical',
|
||||
thinned: 'thinned',
|
||||
className:
|
||||
'[mask-image:linear-gradient(to_bottom,transparent,black_20%,black_80%,transparent)]',
|
||||
},
|
||||
{
|
||||
direction: 'vertical',
|
||||
thinned: 'thinned-start',
|
||||
className: '[mask-image:linear-gradient(to_bottom,transparent,black_20%,black)]',
|
||||
},
|
||||
{
|
||||
direction: 'vertical',
|
||||
thinned: 'thinned-end',
|
||||
className: '[mask-image:linear-gradient(to_bottom,black,black_80%,transparent)]',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* A separator component for dividing content sections.
|
||||
@@ -72,34 +113,12 @@ export const Separator = memo(function Separator({
|
||||
const defaultColor = type === 'single' ? 'outline-light' : 'outline';
|
||||
const finalColor = color ?? defaultColor;
|
||||
|
||||
// Color mapping to CSS custom properties
|
||||
const colorClasses = {
|
||||
black: 'border-foreground',
|
||||
outline: 'border-[var(--rb-outline)]',
|
||||
'outline-light': 'border-[var(--rb-outline)]/40',
|
||||
};
|
||||
|
||||
// Base line styles
|
||||
const lineBaseClasses = [
|
||||
'border-0',
|
||||
'border-solid',
|
||||
colorClasses[finalColor],
|
||||
// 1px stroke for subtler appearance
|
||||
direction === 'horizontal' ? 'border-t' : 'border-l',
|
||||
];
|
||||
|
||||
// Thinned effect using CSS mask
|
||||
const thinnedClasses = thinned !== 'none' ? [THINNED_TYPE_CLASSES[direction][thinned]] : [];
|
||||
|
||||
// Size classes - fill container by default
|
||||
const sizeClasses =
|
||||
direction === 'horizontal'
|
||||
? ['w-full', 'h-0'] // Full width, no height (border provides the visual)
|
||||
: ['h-full', 'w-0']; // Full height, no width (border provides the visual)
|
||||
|
||||
// Single line implementation
|
||||
if (type === 'single') {
|
||||
const lineClasses = cn(...lineBaseClasses, ...thinnedClasses, ...sizeClasses, className);
|
||||
const lineClasses = cn(
|
||||
separatorLineVariants({ color: finalColor, direction, thinned }),
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -112,7 +131,7 @@ export const Separator = memo(function Separator({
|
||||
}
|
||||
|
||||
// Double line implementation
|
||||
const singleLineClasses = cn(...lineBaseClasses, ...thinnedClasses, ...sizeClasses);
|
||||
const singleLineClasses = separatorLineVariants({ color: finalColor, direction, thinned });
|
||||
|
||||
const containerClasses = cn(direction === 'horizontal' ? 'w-full' : 'h-full', className);
|
||||
|
||||
|
||||
@@ -1,47 +1,10 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import type { SpacingScale } from '../../lib/scale';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
/**
|
||||
* Tailwind gap spacing scale values.
|
||||
*/
|
||||
type GapScale =
|
||||
| 0
|
||||
| 0.5
|
||||
| 1
|
||||
| 1.5
|
||||
| 2
|
||||
| 2.5
|
||||
| 3
|
||||
| 3.5
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
| 7
|
||||
| 8
|
||||
| 9
|
||||
| 10
|
||||
| 11
|
||||
| 12
|
||||
| 14
|
||||
| 16
|
||||
| 20
|
||||
| 24
|
||||
| 28
|
||||
| 32
|
||||
| 36
|
||||
| 40
|
||||
| 44
|
||||
| 48
|
||||
| 52
|
||||
| 56
|
||||
| 60
|
||||
| 64
|
||||
| 72
|
||||
| 80
|
||||
| 96;
|
||||
|
||||
/**
|
||||
* Flexbox direction values.
|
||||
*/
|
||||
@@ -77,13 +40,102 @@ export interface StackProps extends ClassNameProps, TestIdProps {
|
||||
/** Whether items should wrap to new lines/columns */
|
||||
wrap?: FlexWrap;
|
||||
/** Spacing between items using Tailwind's spacing scale */
|
||||
gap?: GapScale;
|
||||
gap?: SpacingScale;
|
||||
/** Whether to enable scrolling when content overflows */
|
||||
scrollable?: boolean;
|
||||
/** The HTML element to use for rendering */
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
const stackVariants = cva('flex', {
|
||||
variants: {
|
||||
direction: {
|
||||
row: 'flex-row',
|
||||
col: 'flex-col',
|
||||
'row-reverse': 'flex-row-reverse',
|
||||
'col-reverse': 'flex-col-reverse',
|
||||
},
|
||||
justify: {
|
||||
start: 'justify-start',
|
||||
end: 'justify-end',
|
||||
center: 'justify-center',
|
||||
between: 'justify-between',
|
||||
around: 'justify-around',
|
||||
evenly: 'justify-evenly',
|
||||
},
|
||||
align: {
|
||||
start: 'items-start',
|
||||
end: 'items-end',
|
||||
center: 'items-center',
|
||||
baseline: 'items-baseline',
|
||||
stretch: 'items-stretch',
|
||||
},
|
||||
wrap: {
|
||||
nowrap: 'flex-nowrap',
|
||||
wrap: 'flex-wrap',
|
||||
'wrap-reverse': 'flex-wrap-reverse',
|
||||
},
|
||||
// Convert gap scale to Tailwind class using explicit mapping
|
||||
// This ensures all gap classes are detected by Tailwind's static analysis
|
||||
gap: {
|
||||
0: '',
|
||||
0.5: 'gap-0.5',
|
||||
1: 'gap-1',
|
||||
1.5: 'gap-1.5',
|
||||
2: 'gap-2',
|
||||
2.5: 'gap-2.5',
|
||||
3: 'gap-3',
|
||||
3.5: 'gap-3.5',
|
||||
4: 'gap-4',
|
||||
5: 'gap-5',
|
||||
6: 'gap-6',
|
||||
7: 'gap-7',
|
||||
8: 'gap-8',
|
||||
9: 'gap-9',
|
||||
10: 'gap-10',
|
||||
11: 'gap-11',
|
||||
12: 'gap-12',
|
||||
14: 'gap-14',
|
||||
16: 'gap-16',
|
||||
20: 'gap-20',
|
||||
24: 'gap-24',
|
||||
28: 'gap-28',
|
||||
32: 'gap-32',
|
||||
36: 'gap-36',
|
||||
40: 'gap-40',
|
||||
44: 'gap-44',
|
||||
48: 'gap-48',
|
||||
52: 'gap-52',
|
||||
56: 'gap-56',
|
||||
60: 'gap-60',
|
||||
64: 'gap-64',
|
||||
72: 'gap-72',
|
||||
80: 'gap-80',
|
||||
96: 'gap-96',
|
||||
},
|
||||
scrollable: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
// Scroll axis depends on both scrollable and direction:
|
||||
// row directions overflow on the x-axis, column directions on the y-axis.
|
||||
compoundVariants: [
|
||||
{ scrollable: true, direction: 'row', className: 'overflow-x-auto' },
|
||||
{ scrollable: true, direction: 'row-reverse', className: 'overflow-x-auto' },
|
||||
{ scrollable: true, direction: 'col', className: 'overflow-y-auto' },
|
||||
{ scrollable: true, direction: 'col-reverse', className: 'overflow-y-auto' },
|
||||
],
|
||||
defaultVariants: {
|
||||
direction: 'row',
|
||||
justify: 'start',
|
||||
align: 'start',
|
||||
wrap: 'nowrap',
|
||||
gap: 0,
|
||||
scrollable: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A flexible layout component that arranges children using CSS Flexbox.
|
||||
*
|
||||
@@ -127,96 +179,15 @@ const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack(
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const directionClasses = {
|
||||
row: 'flex-row',
|
||||
col: 'flex-col',
|
||||
'row-reverse': 'flex-row-reverse',
|
||||
'col-reverse': 'flex-col-reverse',
|
||||
} as const;
|
||||
|
||||
const justifyClasses = {
|
||||
start: 'justify-start',
|
||||
end: 'justify-end',
|
||||
center: 'justify-center',
|
||||
between: 'justify-between',
|
||||
around: 'justify-around',
|
||||
evenly: 'justify-evenly',
|
||||
} as const;
|
||||
|
||||
const alignClasses = {
|
||||
start: 'items-start',
|
||||
end: 'items-end',
|
||||
center: 'items-center',
|
||||
baseline: 'items-baseline',
|
||||
stretch: 'items-stretch',
|
||||
} as const;
|
||||
|
||||
const wrapClasses = {
|
||||
nowrap: 'flex-nowrap',
|
||||
wrap: 'flex-wrap',
|
||||
'wrap-reverse': 'flex-wrap-reverse',
|
||||
} as const;
|
||||
|
||||
// Convert gap scale to Tailwind class using explicit mapping
|
||||
// This ensures all gap classes are detected by Tailwind's static analysis
|
||||
const gapClasses = {
|
||||
0: '',
|
||||
0.5: 'gap-0.5',
|
||||
1: 'gap-1',
|
||||
1.5: 'gap-1.5',
|
||||
2: 'gap-2',
|
||||
2.5: 'gap-2.5',
|
||||
3: 'gap-3',
|
||||
3.5: 'gap-3.5',
|
||||
4: 'gap-4',
|
||||
5: 'gap-5',
|
||||
6: 'gap-6',
|
||||
7: 'gap-7',
|
||||
8: 'gap-8',
|
||||
9: 'gap-9',
|
||||
10: 'gap-10',
|
||||
11: 'gap-10',
|
||||
12: 'gap-12',
|
||||
14: 'gap-14',
|
||||
16: 'gap-16',
|
||||
20: 'gap-20',
|
||||
24: 'gap-24',
|
||||
28: 'gap-28',
|
||||
32: 'gap-32',
|
||||
36: 'gap-36',
|
||||
40: 'gap-40',
|
||||
44: 'gap-44',
|
||||
48: 'gap-48',
|
||||
52: 'gap-52',
|
||||
56: 'gap-56',
|
||||
60: 'gap-60',
|
||||
64: 'gap-64',
|
||||
72: 'gap-72',
|
||||
80: 'gap-80',
|
||||
96: 'gap-96',
|
||||
} as const;
|
||||
|
||||
const gapClass = gapClasses[gap];
|
||||
|
||||
const scrollClasses = scrollable
|
||||
? direction.includes('row')
|
||||
? 'overflow-x-auto'
|
||||
: 'overflow-y-auto'
|
||||
: '';
|
||||
|
||||
const stackClasses = cn(
|
||||
'flex',
|
||||
directionClasses[direction],
|
||||
justifyClasses[justify],
|
||||
alignClasses[align],
|
||||
wrapClasses[wrap],
|
||||
gapClass,
|
||||
scrollClasses,
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<Component ref={castRef<HTMLElement>(ref)} className={stackClasses} data-testid={testId}>
|
||||
<Component
|
||||
ref={castRef<HTMLElement>(ref)}
|
||||
className={cn(
|
||||
stackVariants({ direction, justify, align, wrap, gap, scrollable }),
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
@@ -17,6 +18,26 @@ export interface StubProps {
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
const stubVariants = cva('bg-[var(--rb-color-neutral-100)] dark:bg-[var(--rb-color-neutral-800)]', {
|
||||
variants: {
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
animate: {
|
||||
true: 'animate-shimmer',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
rounded: 'md',
|
||||
animate: true,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A skeleton placeholder component with a shimmering animation.
|
||||
* Used to indicate loading states while maintaining layout stability.
|
||||
@@ -38,22 +59,9 @@ export const Stub: React.FC<StubProps> = ({
|
||||
className,
|
||||
animate = true,
|
||||
}) => {
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-[var(--rb-color-neutral-100)] dark:bg-[var(--rb-color-neutral-800)]',
|
||||
animate && 'animate-shimmer',
|
||||
roundedClasses[rounded],
|
||||
className
|
||||
)}
|
||||
className={cn(stubVariants({ rounded, animate }), className)}
|
||||
data-testid="stub"
|
||||
style={{ width, height }}
|
||||
/>
|
||||
|
||||
@@ -46,4 +46,25 @@ describe('components/Switch', () => {
|
||||
await user.click(checkbox);
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies correct Tailwind classes to track and thumb when selected/unselected', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Switch color="primary" variant="solid">Switch</Switch>);
|
||||
|
||||
const track = container.querySelector('[aria-hidden="true"]');
|
||||
const thumb = track?.firstElementChild;
|
||||
|
||||
// Unselected state
|
||||
expect(track).toHaveClass('data-[selected=false]:bg-muted');
|
||||
expect(thumb).toHaveClass('data-[selected=false]:bg-[var(--rb-text)]');
|
||||
|
||||
// Toggle
|
||||
const checkbox = screen.getByRole('switch');
|
||||
await user.click(checkbox);
|
||||
|
||||
// Selected state
|
||||
expect(track).toHaveClass('data-[selected=true]:bg-primary');
|
||||
expect(thumb).toHaveClass('data-[selected=true]:bg-primary-text');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { useRef } from 'react';
|
||||
import { useSwitch, useFocusRing, VisuallyHidden } from 'react-aria';
|
||||
import { useToggleState } from 'react-stately';
|
||||
@@ -41,6 +42,166 @@ export interface SwitchProps extends AriaSwitchProps {
|
||||
color?: SwitchColor;
|
||||
}
|
||||
|
||||
const trackVariants = cva(
|
||||
'relative w-11 h-6 transition-all duration-200 ease-in-out rounded-full outline-none flex items-center',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'data-[selected=false]:bg-muted',
|
||||
outline: 'border-2 data-[selected=false]:bg-transparent data-[selected=false]:border-muted',
|
||||
ghost: 'data-[selected=false]:bg-transparent data-[selected=false]:border-transparent',
|
||||
},
|
||||
color: {
|
||||
primary: '',
|
||||
'primary-negative': '',
|
||||
secondary: '',
|
||||
'secondary-negative': '',
|
||||
tertiary: '',
|
||||
'tertiary-negative': '',
|
||||
error: '',
|
||||
'error-negative': '',
|
||||
neutral: '',
|
||||
'neutral-negative': '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// primary
|
||||
{ variant: 'solid', color: 'primary', className: 'data-[selected=true]:bg-primary data-[selected=true]:border-primary' },
|
||||
{ variant: 'outline', color: 'primary', className: 'data-[selected=true]:bg-primary data-[selected=true]:border-primary' },
|
||||
{ variant: 'ghost', color: 'primary', className: 'data-[selected=true]:bg-primary/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// primary-negative
|
||||
{ variant: 'solid', color: 'primary-negative', className: 'data-[selected=true]:bg-primary-text data-[selected=true]:border-primary-text' },
|
||||
{ variant: 'outline', color: 'primary-negative', className: 'data-[selected=true]:bg-primary-text data-[selected=true]:border-primary-text' },
|
||||
{ variant: 'ghost', color: 'primary-negative', className: 'data-[selected=true]:bg-primary-text/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// secondary
|
||||
{ variant: 'solid', color: 'secondary', className: 'data-[selected=true]:bg-secondary data-[selected=true]:border-secondary' },
|
||||
{ variant: 'outline', color: 'secondary', className: 'data-[selected=true]:bg-secondary data-[selected=true]:border-secondary' },
|
||||
{ variant: 'ghost', color: 'secondary', className: 'data-[selected=true]:bg-secondary/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// secondary-negative
|
||||
{ variant: 'solid', color: 'secondary-negative', className: 'data-[selected=true]:bg-secondary-text data-[selected=true]:border-secondary-text' },
|
||||
{ variant: 'outline', color: 'secondary-negative', className: 'data-[selected=true]:bg-secondary-text data-[selected=true]:border-secondary-text' },
|
||||
{ variant: 'ghost', color: 'secondary-negative', className: 'data-[selected=true]:bg-secondary-text/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// tertiary
|
||||
{ variant: 'solid', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary data-[selected=true]:border-tertiary' },
|
||||
{ variant: 'outline', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary data-[selected=true]:border-tertiary' },
|
||||
{ variant: 'ghost', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// tertiary-negative
|
||||
{ variant: 'solid', color: 'tertiary-negative', className: 'data-[selected=true]:bg-tertiary-text data-[selected=true]:border-tertiary-text' },
|
||||
{ variant: 'outline', color: 'tertiary-negative', className: 'data-[selected=true]:bg-tertiary-text data-[selected=true]:border-tertiary-text' },
|
||||
{ variant: 'ghost', color: 'tertiary-negative', className: 'data-[selected=true]:bg-tertiary-text/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// error
|
||||
{ variant: 'solid', color: 'error', className: 'data-[selected=true]:bg-error data-[selected=true]:border-error' },
|
||||
{ variant: 'outline', color: 'error', className: 'data-[selected=true]:bg-error data-[selected=true]:border-error' },
|
||||
{ variant: 'ghost', color: 'error', className: 'data-[selected=true]:bg-error/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// error-negative
|
||||
{ variant: 'solid', color: 'error-negative', className: 'data-[selected=true]:bg-error-text data-[selected=true]:border-error-text' },
|
||||
{ variant: 'outline', color: 'error-negative', className: 'data-[selected=true]:bg-error-text data-[selected=true]:border-error-text' },
|
||||
{ variant: 'ghost', color: 'error-negative', className: 'data-[selected=true]:bg-error-text/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// neutral
|
||||
{ variant: 'solid', color: 'neutral', className: 'data-[selected=true]:bg-muted data-[selected=true]:border-muted' },
|
||||
{ variant: 'outline', color: 'neutral', className: 'data-[selected=true]:bg-muted data-[selected=true]:border-muted' },
|
||||
{ variant: 'ghost', color: 'neutral', className: 'data-[selected=true]:bg-muted/20 data-[selected=true]:border-transparent' },
|
||||
|
||||
// neutral-negative
|
||||
{ variant: 'solid', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-text)] data-[selected=true]:border-[var(--rb-text)]' },
|
||||
{ variant: 'outline', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-text)] data-[selected=true]:border-[var(--rb-text)]' },
|
||||
{ variant: 'ghost', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]/20 data-[selected=true]:border-transparent' },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
color: 'primary',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const thumbVariants = cva(
|
||||
'w-4 h-4 transition-all duration-200 ease-in-out transform rounded-full shadow-sm data-[selected=false]:bg-[var(--rb-text)] data-[selected=false]:translate-x-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'ml-1 data-[selected=true]:translate-x-5',
|
||||
outline: 'ml-0.5 data-[selected=true]:translate-x-4.5',
|
||||
ghost: 'ml-1 data-[selected=true]:translate-x-5',
|
||||
},
|
||||
color: {
|
||||
primary: '',
|
||||
'primary-negative': '',
|
||||
secondary: '',
|
||||
'secondary-negative': '',
|
||||
tertiary: '',
|
||||
'tertiary-negative': '',
|
||||
error: '',
|
||||
'error-negative': '',
|
||||
neutral: '',
|
||||
'neutral-negative': '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// primary
|
||||
{ variant: 'solid', color: 'primary', className: 'data-[selected=true]:bg-primary-text' },
|
||||
{ variant: 'outline', color: 'primary', className: 'data-[selected=true]:bg-primary-text' },
|
||||
{ variant: 'ghost', color: 'primary', className: 'data-[selected=true]:bg-primary' },
|
||||
|
||||
// primary-negative
|
||||
{ variant: 'solid', color: 'primary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'outline', color: 'primary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'ghost', color: 'primary-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
|
||||
// secondary
|
||||
{ variant: 'solid', color: 'secondary', className: 'data-[selected=true]:bg-secondary-text' },
|
||||
{ variant: 'outline', color: 'secondary', className: 'data-[selected=true]:bg-secondary-text' },
|
||||
{ variant: 'ghost', color: 'secondary', className: 'data-[selected=true]:bg-secondary' },
|
||||
|
||||
// secondary-negative
|
||||
{ variant: 'solid', color: 'secondary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'outline', color: 'secondary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'ghost', color: 'secondary-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
|
||||
// tertiary
|
||||
{ variant: 'solid', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary-text' },
|
||||
{ variant: 'outline', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary-text' },
|
||||
{ variant: 'ghost', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary' },
|
||||
|
||||
// tertiary-negative
|
||||
{ variant: 'solid', color: 'tertiary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'outline', color: 'tertiary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'ghost', color: 'tertiary-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
|
||||
// error
|
||||
{ variant: 'solid', color: 'error', className: 'data-[selected=true]:bg-error-text' },
|
||||
{ variant: 'outline', color: 'error', className: 'data-[selected=true]:bg-error-text' },
|
||||
{ variant: 'ghost', color: 'error', className: 'data-[selected=true]:bg-error' },
|
||||
|
||||
// error-negative
|
||||
{ variant: 'solid', color: 'error-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'outline', color: 'error-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'ghost', color: 'error-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
|
||||
// neutral
|
||||
{ variant: 'solid', color: 'neutral', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
{ variant: 'outline', color: 'neutral', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
{ variant: 'ghost', color: 'neutral', className: 'data-[selected=true]:bg-muted' },
|
||||
|
||||
// neutral-negative
|
||||
{ variant: 'solid', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'outline', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
|
||||
{ variant: 'ghost', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
color: 'primary',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A highly accessible toggle switch component.
|
||||
*
|
||||
@@ -60,66 +221,6 @@ export function Switch(props: SwitchProps): React.JSX.Element {
|
||||
const { inputProps } = useSwitch(props, state, ref);
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
|
||||
const getVariantStyles = (): React.CSSProperties => {
|
||||
const isNegative = color.endsWith('-negative');
|
||||
const baseColor = color.replace('-negative', '');
|
||||
|
||||
const colors: Record<string, { base: string; text: string }> = {
|
||||
primary: { base: 'var(--rb-primary-base)', text: 'var(--rb-primary-text)' },
|
||||
secondary: { base: 'var(--rb-secondary-base)', text: 'var(--rb-secondary-text)' },
|
||||
tertiary: { base: 'var(--rb-tertiary-base)', text: 'var(--rb-tertiary-text)' },
|
||||
error: { base: 'var(--rb-error-base)', text: 'var(--rb-error-text)' },
|
||||
neutral: { base: 'var(--rb-muted-base)', text: 'var(--rb-text)' },
|
||||
};
|
||||
|
||||
const colorValues = colors[baseColor];
|
||||
const backgroundColor = isNegative ? colorValues.text : colorValues.base;
|
||||
|
||||
if (!state.isSelected) {
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'solid':
|
||||
return { backgroundColor };
|
||||
case 'outline':
|
||||
return { borderColor: backgroundColor };
|
||||
case 'ghost':
|
||||
return { backgroundColor: `${backgroundColor}33`, borderColor: 'transparent' }; // 33 is 20% opacity in hex
|
||||
default:
|
||||
return { backgroundColor };
|
||||
}
|
||||
};
|
||||
|
||||
const getThumbStyles = (): React.CSSProperties => {
|
||||
const isNegative = color.endsWith('-negative');
|
||||
const baseColor = color.replace('-negative', '');
|
||||
|
||||
const colors: Record<string, { text: string }> = {
|
||||
primary: { text: 'var(--rb-primary-text)' },
|
||||
secondary: { text: 'var(--rb-secondary-text)' },
|
||||
tertiary: { text: 'var(--rb-tertiary-text)' },
|
||||
error: { text: 'var(--rb-error-text)' },
|
||||
neutral: { text: 'var(--rb-text)' },
|
||||
};
|
||||
|
||||
const thumbColor = isNegative ? 'var(--rb-base)' : colors[baseColor].text;
|
||||
|
||||
if (!state.isSelected) {
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'solid':
|
||||
return { backgroundColor: thumbColor };
|
||||
case 'outline':
|
||||
case 'ghost':
|
||||
return { backgroundColor: isNegative ? 'var(--rb-text)' : `var(--rb-${baseColor}-base)` };
|
||||
default:
|
||||
return { backgroundColor: thumbColor };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
@@ -134,31 +235,15 @@ export function Switch(props: SwitchProps): React.JSX.Element {
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'relative w-11 h-6 transition-all duration-200 ease-in-out rounded-full outline-none flex items-center',
|
||||
!state.isSelected &&
|
||||
(variant === 'solid'
|
||||
? 'bg-[var(--rb-muted-base)]'
|
||||
: variant === 'outline'
|
||||
? 'bg-transparent border-[var(--rb-muted-base)] border-2'
|
||||
: 'bg-transparent border-transparent'),
|
||||
state.isSelected && variant === 'outline' && 'bg-transparent border-2',
|
||||
trackVariants({ variant, color }),
|
||||
isFocusVisible && 'ring-2 ring-offset-2 ring-[var(--rb-ring)]',
|
||||
props.isDisabled && 'grayscale-[0.5]'
|
||||
)}
|
||||
style={getVariantStyles()}
|
||||
data-selected={state.isSelected}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-4 h-4 transition-all duration-200 ease-in-out transform rounded-full shadow-sm',
|
||||
!state.isSelected && 'bg-[var(--rb-text)]',
|
||||
variant === 'outline' ? 'ml-0.5' : 'ml-1',
|
||||
state.isSelected
|
||||
? variant === 'outline'
|
||||
? 'translate-x-4.5'
|
||||
: 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
)}
|
||||
style={getThumbStyles()}
|
||||
className={thumbVariants({ variant, color })}
|
||||
data-selected={state.isSelected}
|
||||
/>
|
||||
</div>
|
||||
{children && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { TableContext } from './contexts/TableContext/TableContext';
|
||||
@@ -26,6 +27,34 @@ export interface TableProps
|
||||
stickyHeader?: boolean;
|
||||
}
|
||||
|
||||
const tableVariants = cva(
|
||||
cn(
|
||||
'w-full border-collapse caption-bottom text-sm',
|
||||
// Mask borders at the edges to prevent doubling with container borders
|
||||
'[&_td:first-child]:border-l-0 [&_th:first-child]:border-l-0',
|
||||
'[&_td:last-child]:border-r-0 [&_th:last-child]:border-r-0',
|
||||
'[&_tr:first-child_td]:border-t-0 [&_tr:first-child_th]:border-t-0',
|
||||
'[&_tr:last-child_td]:border-b-0'
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
surface: '',
|
||||
outline: '',
|
||||
ghost: 'border-none',
|
||||
},
|
||||
stickyHeader: {
|
||||
true: 'relative',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'surface',
|
||||
stickyHeader: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const TableComponent = forwardRef<HTMLTableElement, TableProps>(function Table(
|
||||
{
|
||||
className,
|
||||
@@ -47,17 +76,7 @@ const TableComponent = forwardRef<HTMLTableElement, TableProps>(function Table(
|
||||
<table
|
||||
{...props}
|
||||
ref={castRef<HTMLTableElement>(ref)}
|
||||
className={cn(
|
||||
'w-full border-collapse caption-bottom text-sm',
|
||||
// Mask borders at the edges to prevent doubling with container borders
|
||||
'[&_td:first-child]:border-l-0 [&_th:first-child]:border-l-0',
|
||||
'[&_td:last-child]:border-r-0 [&_th:last-child]:border-r-0',
|
||||
'[&_tr:first-child_td]:border-t-0 [&_tr:first-child_th]:border-t-0',
|
||||
'[&_tr:last-child_td]:border-b-0',
|
||||
variant === 'ghost' && 'border-none',
|
||||
stickyHeader && 'relative',
|
||||
className
|
||||
)}
|
||||
className={cn(tableVariants({ variant, stickyHeader }), className)}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { paddingClasses, borderClasses } from '../constants';
|
||||
@@ -23,6 +24,18 @@ export interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElem
|
||||
border?: TableCellBorder;
|
||||
}
|
||||
|
||||
const cellVariants = cva('align-middle', {
|
||||
variants: {
|
||||
padding: paddingClasses,
|
||||
border: borderClasses,
|
||||
align: {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A standard table data cell (td) with density and alignment control.
|
||||
*
|
||||
@@ -65,12 +78,7 @@ export const TableCell = genericMemo(
|
||||
{...props}
|
||||
ref={castRef<HTMLTableCellElement>(ref)}
|
||||
className={cn(
|
||||
'align-middle',
|
||||
paddingClasses[finalPadding],
|
||||
borderClasses[resolvedBorder],
|
||||
align === 'left' && 'text-left',
|
||||
align === 'center' && 'text-center',
|
||||
align === 'right' && 'text-right',
|
||||
cellVariants({ padding: finalPadding, border: resolvedBorder, align }),
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
/**
|
||||
@@ -36,18 +37,49 @@ export interface TableContentProps extends React.HTMLAttributes<HTMLDivElement>
|
||||
color?: 'default' | 'muted' | 'error' | 'success';
|
||||
}
|
||||
|
||||
const gapClasses: Record<string, string> = {
|
||||
s: 'gap-1.5',
|
||||
m: 'gap-3',
|
||||
l: 'gap-4',
|
||||
};
|
||||
|
||||
const colorClasses: Record<string, string> = {
|
||||
default: 'text-[var(--rb-text)]',
|
||||
muted: 'text-[var(--rb-muted-text)]',
|
||||
error: 'text-[var(--rb-error-text)]',
|
||||
success: 'text-[var(--rb-success-text)]',
|
||||
};
|
||||
const contentVariants = cva('flex', {
|
||||
variants: {
|
||||
layout: {
|
||||
flex: 'flex-row items-center',
|
||||
stack: 'flex-col',
|
||||
},
|
||||
align: {
|
||||
start: '',
|
||||
center: '',
|
||||
end: '',
|
||||
between: '',
|
||||
},
|
||||
gap: {
|
||||
s: 'gap-1.5',
|
||||
m: 'gap-3',
|
||||
l: 'gap-4',
|
||||
},
|
||||
color: {
|
||||
default: 'text-[var(--rb-text)]',
|
||||
muted: 'text-[var(--rb-muted-text)]',
|
||||
error: 'text-[var(--rb-error-text)]',
|
||||
success: 'text-[var(--rb-success-text)]',
|
||||
},
|
||||
variant: {
|
||||
text: '',
|
||||
numeric: 'tabular-nums',
|
||||
icon: '',
|
||||
checkbox: 'w-min mx-auto',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Flex layout maps alignment to justify-*
|
||||
{ layout: 'flex', align: 'start', className: 'justify-start' },
|
||||
{ layout: 'flex', align: 'center', className: 'justify-center' },
|
||||
{ layout: 'flex', align: 'end', className: 'justify-end' },
|
||||
{ layout: 'flex', align: 'between', className: 'justify-between' },
|
||||
// Stack layout maps alignment to items-*
|
||||
{ layout: 'stack', align: 'start', className: 'items-start' },
|
||||
{ layout: 'stack', align: 'center', className: 'items-center' },
|
||||
{ layout: 'stack', align: 'end', className: 'items-end' },
|
||||
{ layout: 'stack', align: 'between', className: 'items-stretch' },
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* A polymorphic layout primitive for content within table cells.
|
||||
@@ -85,20 +117,12 @@ export const TableContent = genericMemo(
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const alignClasses = {
|
||||
start: layout === 'flex' ? 'justify-start' : 'items-start',
|
||||
center: layout === 'flex' ? 'justify-center' : 'items-center',
|
||||
end: layout === 'flex' ? 'justify-end' : 'items-end',
|
||||
between: layout === 'flex' ? 'justify-between' : 'items-stretch',
|
||||
};
|
||||
|
||||
// Determine default alignment based on variant if not explicitly provided
|
||||
let defaultAlign: keyof typeof alignClasses = 'start';
|
||||
let defaultAlign: NonNullable<TableContentProps['align']> = 'start';
|
||||
if (variant === 'numeric') defaultAlign = 'end';
|
||||
else if (variant === 'icon' || variant === 'checkbox') defaultAlign = 'center';
|
||||
|
||||
const finalAlign = align ?? defaultAlign;
|
||||
const alignmentClass = alignClasses[finalAlign];
|
||||
|
||||
// Truncation logic
|
||||
const isTruncated = truncate || maxLines !== undefined;
|
||||
@@ -109,16 +133,10 @@ export const TableContent = genericMemo(
|
||||
{...props}
|
||||
ref={castRef<HTMLDivElement>(ref)}
|
||||
className={cn(
|
||||
'flex',
|
||||
layout === 'flex' ? 'flex-row items-center' : 'flex-col',
|
||||
alignmentClass,
|
||||
gap && gapClasses[gap],
|
||||
colorClasses[color],
|
||||
contentVariants({ layout, align: finalAlign, gap, color, variant }),
|
||||
// When truncating, we need to handle block layout and min-width
|
||||
isTruncated && (lineCount === 1 ? 'truncate block' : 'line-clamp'),
|
||||
isTruncated && 'min-w-0',
|
||||
variant === 'numeric' && 'tabular-nums',
|
||||
variant === 'checkbox' && 'w-min mx-auto',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { paddingClasses, borderClasses } from '../constants';
|
||||
@@ -23,6 +24,18 @@ export interface TableHeadProps extends React.ThHTMLAttributes<HTMLTableCellElem
|
||||
border?: TableCellBorder;
|
||||
}
|
||||
|
||||
const headVariants = cva('align-middle font-medium text-[var(--rb-muted-text)]', {
|
||||
variants: {
|
||||
padding: paddingClasses,
|
||||
border: borderClasses,
|
||||
align: {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A table header cell (th) with density and alignment control.
|
||||
*
|
||||
@@ -60,12 +73,7 @@ export const TableHead = genericMemo(
|
||||
{...props}
|
||||
ref={castRef<HTMLTableCellElement>(ref)}
|
||||
className={cn(
|
||||
'align-middle font-medium text-[var(--rb-muted-text)]',
|
||||
paddingClasses[finalPadding],
|
||||
borderClasses[resolvedBorder],
|
||||
align === 'left' && 'text-left',
|
||||
align === 'center' && 'text-center',
|
||||
align === 'right' && 'text-right',
|
||||
headVariants({ padding: finalPadding, border: resolvedBorder, align }),
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { useTableContext } from '../contexts/TableContext/TableContext';
|
||||
@@ -25,6 +26,28 @@ export interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement>
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
const rowVariants = cva('transition-colors', {
|
||||
variants: {
|
||||
hoverable: {
|
||||
true: 'hover:bg-[var(--rb-muted-base)]/50',
|
||||
false: '',
|
||||
},
|
||||
striped: {
|
||||
true: 'even:bg-[var(--rb-muted-base)]/20',
|
||||
false: '',
|
||||
},
|
||||
selected: {
|
||||
true: 'bg-[var(--rb-muted-base)]',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
hoverable: false,
|
||||
striped: false,
|
||||
selected: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A table row component (tr) with hover, stripe, and border controls.
|
||||
*
|
||||
@@ -53,10 +76,7 @@ export const TableRow = genericMemo(
|
||||
{...props}
|
||||
ref={castRef<HTMLTableRowElement>(ref)}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
isHoverable && 'hover:bg-[var(--rb-muted-base)]/50',
|
||||
context.striped && 'even:bg-[var(--rb-muted-base)]/20',
|
||||
selected && 'bg-[var(--rb-muted-base)]',
|
||||
rowVariants({ hoverable: isHoverable, striped: context.striped, selected }),
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -131,13 +131,13 @@ describe('components/Text', () => {
|
||||
});
|
||||
|
||||
// TDD: fails before implementation (old code uses text-[14px], not text-rb-body-m)
|
||||
it('applies text-rb-{type}-{size} class', () => {
|
||||
it('applies text-body-m class', () => {
|
||||
render(
|
||||
<Text size="m" type="body">
|
||||
Content
|
||||
</Text>
|
||||
);
|
||||
expect(screen.getByText('Content')).toHaveClass('text-rb-body-m');
|
||||
expect(screen.getByText('Content')).toHaveClass('text-body-m');
|
||||
});
|
||||
|
||||
it('does not apply legacy px-based font-size classes', () => {
|
||||
|
||||
@@ -48,6 +48,6 @@ describe('components/Time', () => {
|
||||
|
||||
const timeElement = screen.getByText(/February 10, 2026/i);
|
||||
expect(timeElement).toHaveClass('font-bold');
|
||||
expect(timeElement).toHaveClass('text-[var(--rb-primary-text)]');
|
||||
expect(timeElement).toHaveClass('text-primary-text');
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './scale';
|
||||
export * from './theme';
|
||||
export * from './typography';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './scale';
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Tailwind spacing scale steps.
|
||||
*
|
||||
* Shared across layout components (e.g. `Stack` gaps, `Container` padding) so
|
||||
* the design system exposes a single, consistent set of spacing values. Values
|
||||
* mirror Tailwind's default spacing scale.
|
||||
*/
|
||||
export type SpacingScale =
|
||||
| 0
|
||||
| 0.5
|
||||
| 1
|
||||
| 1.5
|
||||
| 2
|
||||
| 2.5
|
||||
| 3
|
||||
| 3.5
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
| 7
|
||||
| 8
|
||||
| 9
|
||||
| 10
|
||||
| 11
|
||||
| 12
|
||||
| 14
|
||||
| 16
|
||||
| 20
|
||||
| 24
|
||||
| 28
|
||||
| 32
|
||||
| 36
|
||||
| 40
|
||||
| 44
|
||||
| 48
|
||||
| 52
|
||||
| 56
|
||||
| 60
|
||||
| 64
|
||||
| 72
|
||||
| 80
|
||||
| 96;
|
||||
+11
-11
@@ -2,20 +2,20 @@ import { getTypographyClassNames } from './getTypographyClassNames';
|
||||
|
||||
describe('lib/typography/getTypographyClassNames', () => {
|
||||
describe('type + size → @theme token class', () => {
|
||||
it('returns text-rb-body-m by default', () => {
|
||||
expect(getTypographyClassNames()).toContain('text-rb-body-m');
|
||||
it('returns text-body-m by default', () => {
|
||||
expect(getTypographyClassNames()).toContain('text-body-m');
|
||||
});
|
||||
|
||||
it('returns correct class for each type/size combination', () => {
|
||||
expect(getTypographyClassNames({ type: 'display', size: 'l' })).toContain(
|
||||
'text-rb-display-l'
|
||||
'text-display-l'
|
||||
);
|
||||
expect(getTypographyClassNames({ type: 'headline', size: 'm' })).toContain(
|
||||
'text-rb-headline-m'
|
||||
'text-headline-m'
|
||||
);
|
||||
expect(getTypographyClassNames({ type: 'title', size: 's' })).toContain('text-rb-title-s');
|
||||
expect(getTypographyClassNames({ type: 'body', size: 'l' })).toContain('text-rb-body-l');
|
||||
expect(getTypographyClassNames({ type: 'label', size: 'm' })).toContain('text-rb-label-m');
|
||||
expect(getTypographyClassNames({ type: 'title', size: 's' })).toContain('text-title-s');
|
||||
expect(getTypographyClassNames({ type: 'body', size: 'l' })).toContain('text-body-l');
|
||||
expect(getTypographyClassNames({ type: 'label', size: 'm' })).toContain('text-label-m');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,15 +53,15 @@ describe('lib/typography/getTypographyClassNames', () => {
|
||||
|
||||
describe('color', () => {
|
||||
it('applies default color class', () => {
|
||||
expect(getTypographyClassNames()).toContain('text-[var(--rb-text)]');
|
||||
expect(getTypographyClassNames()).toContain('text-text');
|
||||
});
|
||||
|
||||
it('applies each color variant', () => {
|
||||
expect(getTypographyClassNames({ color: 'muted' })).toContain('text-[var(--rb-muted-text)]');
|
||||
expect(getTypographyClassNames({ color: 'muted' })).toContain('text-muted-text');
|
||||
expect(getTypographyClassNames({ color: 'primary' })).toContain(
|
||||
'text-[var(--rb-primary-text)]'
|
||||
'text-primary-text'
|
||||
);
|
||||
expect(getTypographyClassNames({ color: 'error' })).toContain('text-[var(--rb-error-text)]');
|
||||
expect(getTypographyClassNames({ color: 'error' })).toContain('text-error-text');
|
||||
expect(getTypographyClassNames({ color: 'inherit' })).toContain('text-inherit');
|
||||
});
|
||||
});
|
||||
|
||||
+103
-49
@@ -1,3 +1,5 @@
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
/**
|
||||
* Typography type roles.
|
||||
* Determines font family and base visual treatment.
|
||||
@@ -65,35 +67,92 @@ export interface TypographyOptions {
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
const fontFamilyClasses: Record<TextType, string> = {
|
||||
display: 'font-serif',
|
||||
headline: 'font-serif',
|
||||
title: 'font-sans',
|
||||
body: 'font-sans',
|
||||
label: 'font-sans',
|
||||
};
|
||||
const typographyVariants = cva('', {
|
||||
variants: {
|
||||
type: {
|
||||
display: 'font-serif',
|
||||
headline: 'font-serif',
|
||||
title: 'font-sans',
|
||||
body: 'font-sans',
|
||||
label: 'font-sans font-medium',
|
||||
},
|
||||
size: {
|
||||
s: '',
|
||||
m: '',
|
||||
l: '',
|
||||
},
|
||||
color: {
|
||||
default: 'text-text',
|
||||
primary: 'text-primary-text',
|
||||
secondary: 'text-secondary-text',
|
||||
tertiary: 'text-tertiary-text',
|
||||
error: 'text-error-text',
|
||||
muted: 'text-muted-text',
|
||||
inherit: 'text-inherit',
|
||||
},
|
||||
bold: {
|
||||
true: 'font-bold',
|
||||
false: '',
|
||||
},
|
||||
italic: {
|
||||
true: 'italic',
|
||||
false: '',
|
||||
},
|
||||
underline: {
|
||||
true: 'underline',
|
||||
false: '',
|
||||
},
|
||||
strike: {
|
||||
true: 'line-through',
|
||||
false: '',
|
||||
},
|
||||
align: {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
justify: 'text-justify',
|
||||
},
|
||||
wrap: {
|
||||
nowrap: 'whitespace-nowrap',
|
||||
balance: 'text-balance',
|
||||
pretty: 'text-pretty',
|
||||
},
|
||||
overflow: {
|
||||
ellipsis: 'truncate',
|
||||
clip: 'overflow-clip',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{ type: 'display', size: 's', className: 'text-display-s' },
|
||||
{ type: 'display', size: 'm', className: 'text-display-m' },
|
||||
{ type: 'display', size: 'l', className: 'text-display-l' },
|
||||
{ type: 'headline', size: 's', className: 'text-headline-s' },
|
||||
{ type: 'headline', size: 'm', className: 'text-headline-m' },
|
||||
{ type: 'headline', size: 'l', className: 'text-headline-l' },
|
||||
{ type: 'title', size: 's', className: 'text-title-s' },
|
||||
{ type: 'title', size: 'm', className: 'text-title-m' },
|
||||
{ type: 'title', size: 'l', className: 'text-title-l' },
|
||||
{ type: 'body', size: 's', className: 'text-body-s' },
|
||||
{ type: 'body', size: 'm', className: 'text-body-m' },
|
||||
{ type: 'body', size: 'l', className: 'text-body-l' },
|
||||
{ type: 'label', size: 's', className: 'text-label-s' },
|
||||
{ type: 'label', size: 'm', className: 'text-label-m' },
|
||||
{ type: 'label', size: 'l', className: 'text-label-l' },
|
||||
],
|
||||
defaultVariants: {
|
||||
type: 'body',
|
||||
size: 'm',
|
||||
color: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
const colorClasses: Record<TextColor, string> = {
|
||||
default: 'text-[var(--rb-text)]',
|
||||
primary: 'text-[var(--rb-primary-text)]',
|
||||
secondary: 'text-[var(--rb-secondary-text)]',
|
||||
tertiary: 'text-[var(--rb-tertiary-text)]',
|
||||
error: 'text-[var(--rb-error-text)]',
|
||||
muted: 'text-[var(--rb-muted-text)]',
|
||||
inherit: 'text-inherit',
|
||||
};
|
||||
|
||||
const alignClasses: Record<TextAlign, string> = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
justify: 'text-justify',
|
||||
};
|
||||
|
||||
const wrapClasses: Record<TextWrap, string> = {
|
||||
nowrap: 'whitespace-nowrap',
|
||||
balance: 'text-balance',
|
||||
pretty: 'text-pretty',
|
||||
const lineClampMap: Record<number, string> = {
|
||||
1: 'line-clamp-1',
|
||||
2: 'line-clamp-2',
|
||||
3: 'line-clamp-3',
|
||||
4: 'line-clamp-4',
|
||||
5: 'line-clamp-5',
|
||||
6: 'line-clamp-6',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -102,7 +161,7 @@ const wrapClasses: Record<TextWrap, string> = {
|
||||
*
|
||||
* The returned string is safe to pass directly to `cn()` alongside other
|
||||
* classes — it contains no internal conflicts. Tailwind-merge is configured
|
||||
* (via the stl `cn` utility) to treat `text-rb-{type}-{size}` as a
|
||||
* (via the stl `cn` utility) to treat `text-{type}-{size}` as a
|
||||
* font-size group so it never conflicts with text-color classes.
|
||||
*
|
||||
* @example
|
||||
@@ -133,26 +192,21 @@ export function getTypographyClassNames(options: TypographyOptions = {}): string
|
||||
|
||||
let truncationClass = '';
|
||||
if (maxLines) {
|
||||
truncationClass = `line-clamp-${maxLines}`;
|
||||
} else if (overflow === 'ellipsis') {
|
||||
truncationClass = 'truncate';
|
||||
} else if (overflow === 'clip') {
|
||||
truncationClass = 'overflow-clip';
|
||||
truncationClass = lineClampMap[maxLines] ?? `line-clamp-[${maxLines}]`;
|
||||
}
|
||||
|
||||
return [
|
||||
`text-rb-${type}-${size}`,
|
||||
fontFamilyClasses[type],
|
||||
type === 'label' && 'font-medium',
|
||||
colorClasses[color],
|
||||
bold && 'font-bold',
|
||||
italic && 'italic',
|
||||
underline && 'underline',
|
||||
strike && 'line-through',
|
||||
align && alignClasses[align],
|
||||
wrap && wrapClasses[wrap],
|
||||
truncationClass,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const classNames = typographyVariants({
|
||||
type,
|
||||
size,
|
||||
color,
|
||||
bold,
|
||||
italic,
|
||||
underline,
|
||||
strike,
|
||||
align,
|
||||
wrap,
|
||||
overflow: maxLines ? undefined : overflow,
|
||||
});
|
||||
|
||||
return [classNames, truncationClass].filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -76,10 +76,7 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
/* Safelist: text-rb-{type}-{size} utilities are assembled via template literals
|
||||
in getTypographyClassNames and cannot be detected by Tailwind's static scanner.
|
||||
@source inline forces inclusion regardless of source scanning. */
|
||||
@source inline("text-rb-display-l text-rb-display-m text-rb-display-s text-rb-headline-l text-rb-headline-m text-rb-headline-s text-rb-title-l text-rb-title-m text-rb-title-s text-rb-body-l text-rb-body-m text-rb-body-s text-rb-label-l text-rb-label-m text-rb-label-s");
|
||||
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@@ -257,6 +254,7 @@
|
||||
--rb-input: var(--rb-color-neutral-800);
|
||||
--rb-text: var(--rb-color-neutral-950);
|
||||
--rb-ring: var(--rb-color-cyan-700);
|
||||
--rb-radius-md: 0.375rem;
|
||||
|
||||
/* Container */
|
||||
--rb-container-base: var(--rb-color-neutral-50);
|
||||
@@ -419,3 +417,74 @@
|
||||
color: var(--shiki-dark) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-primary: var(--rb-primary-base);
|
||||
--color-primary-text: var(--rb-primary-text);
|
||||
--color-secondary: var(--rb-secondary-base);
|
||||
--color-secondary-text: var(--rb-secondary-text);
|
||||
--color-tertiary: var(--rb-tertiary-base);
|
||||
--color-tertiary-text: var(--rb-tertiary-text);
|
||||
--color-error: var(--rb-error-base);
|
||||
--color-error-text: var(--rb-error-text);
|
||||
--color-muted: var(--rb-muted-base);
|
||||
--color-muted-text: var(--rb-muted-text);
|
||||
--color-base: var(--rb-base);
|
||||
--color-outline: var(--rb-outline);
|
||||
--color-text: var(--rb-text);
|
||||
|
||||
/* Typography */
|
||||
--text-display-l: var(--text-rb-display-l);
|
||||
--text-display-l--line-height: var(--text-rb-display-l--line-height);
|
||||
--text-display-l--letter-spacing: var(--text-rb-display-l--letter-spacing);
|
||||
--text-display-m: var(--text-rb-display-m);
|
||||
--text-display-m--line-height: var(--text-rb-display-m--line-height);
|
||||
--text-display-m--letter-spacing: var(--text-rb-display-m--letter-spacing);
|
||||
--text-display-s: var(--text-rb-display-s);
|
||||
--text-display-s--line-height: var(--text-rb-display-s--line-height);
|
||||
--text-display-s--letter-spacing: var(--text-rb-display-s--letter-spacing);
|
||||
|
||||
--text-headline-l: var(--text-rb-headline-l);
|
||||
--text-headline-l--line-height: var(--text-rb-headline-l--line-height);
|
||||
--text-headline-l--letter-spacing: var(--text-rb-headline-l--letter-spacing);
|
||||
--text-headline-m: var(--text-rb-headline-m);
|
||||
--text-headline-m--line-height: var(--text-rb-headline-m--line-height);
|
||||
--text-headline-m--letter-spacing: var(--text-rb-headline-m--letter-spacing);
|
||||
--text-headline-s: var(--text-rb-headline-s);
|
||||
--text-headline-s--line-height: var(--text-rb-headline-s--line-height);
|
||||
--text-headline-s--letter-spacing: var(--text-rb-headline-s--letter-spacing);
|
||||
|
||||
--text-title-l: var(--text-rb-title-l);
|
||||
--text-title-l--line-height: var(--text-rb-title-l--line-height);
|
||||
--text-title-l--letter-spacing: var(--text-rb-title-l--letter-spacing);
|
||||
--text-title-m: var(--text-rb-title-m);
|
||||
--text-title-m--line-height: var(--text-rb-title-m--line-height);
|
||||
--text-title-m--letter-spacing: var(--text-rb-title-m--letter-spacing);
|
||||
--text-title-s: var(--text-rb-title-s);
|
||||
--text-title-s--line-height: var(--text-rb-title-s--line-height);
|
||||
--text-title-s--letter-spacing: var(--text-rb-title-s--letter-spacing);
|
||||
|
||||
--text-body-l: var(--text-rb-body-l);
|
||||
--text-body-l--line-height: var(--text-rb-body-l--line-height);
|
||||
--text-body-l--letter-spacing: var(--text-rb-body-l--letter-spacing);
|
||||
--text-body-m: var(--text-rb-body-m);
|
||||
--text-body-m--line-height: var(--text-rb-body-m--line-height);
|
||||
--text-body-m--letter-spacing: var(--text-rb-body-m--letter-spacing);
|
||||
--text-body-s: var(--text-rb-body-s);
|
||||
--text-body-s--line-height: var(--text-rb-body-s--line-height);
|
||||
--text-body-s--letter-spacing: var(--text-rb-body-s--letter-spacing);
|
||||
|
||||
--text-label-l: var(--text-rb-label-l);
|
||||
--text-label-l--line-height: var(--text-rb-label-l--line-height);
|
||||
--text-label-l--letter-spacing: var(--text-rb-label-l--letter-spacing);
|
||||
--text-label-m: var(--text-rb-label-m);
|
||||
--text-label-m--line-height: var(--text-rb-label-m--line-height);
|
||||
--text-label-m--letter-spacing: var(--text-rb-label-m--letter-spacing);
|
||||
--text-label-s: var(--text-rb-label-s);
|
||||
--text-label-s--line-height: var(--text-rb-label-s--line-height);
|
||||
--text-label-s--letter-spacing: var(--text-rb-label-s--letter-spacing);
|
||||
|
||||
/* Utilities */
|
||||
--radius-md: var(--rb-radius-md);
|
||||
}
|
||||
Generated
+1564
-62
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,7 @@ title: "Ermnvldmr's Documentation"
|
||||
Welcome to the unified documentation for my own projects
|
||||
|
||||
{{< cards >}}
|
||||
{{< card link="root" title="ermnvldmr.com-root-commons" icon="server" subtitle="Documentation for the root deployment commons framework" >}}
|
||||
{{< card link="apex" title="apex.ermnvldmr.com" icon="server" subtitle="Fleet operations framework: a stdlib-python engine and a shared compose core" >}}
|
||||
{{< card link="quetza" title="Quetza Bot" icon="terminal" subtitle="Modular Discord bot framework documentation" >}}
|
||||
{{< card link="root" title="ermnvldmr.com-root-commons (deprecated)" icon="archive" subtitle="Deprecated — superseded by the apex framework" >}}
|
||||
{{< /cards >}}
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: Documentation of the apex fleet operations framework
|
||||
description: "Documentation for the apex fleet operations framework: a stdlib-python engine and a shared compose core for self-hosted nodes."
|
||||
weight: 1
|
||||
type: docs
|
||||
cascade:
|
||||
type: docs
|
||||
---
|
||||
|
||||
`apex` is the [commons](/apex/glossary#commons) of a fleet of self-hosted [nodes](/apex/glossary#node): a python3-stdlib [engine](/apex/glossary#engine) that runs grouped [actions](/apex/glossary#action) as `apex <group>/<action>`, paired with a shared, `${env}`-parameterized docker-compose core. Every node repository consumes the framework as the pinned `commons/` git submodule. apex succeeds the bash `root` commons framework.
|
||||
|
||||
The stack is deliberately small: `git` plus the Python 3 standard library. There is no `pip`, Node, Go, or CUE, and compose files are hand-authored — no code generation.
|
||||
|
||||
## Core features
|
||||
|
||||
- **Single entrypoint on the local checkout** — `apex` always acts on the checkout it runs from; there is no `<node>` argument to pass.
|
||||
- **Explicit identity from `node.env`** — [node identity](/apex/glossary#node-identity) is declared per node in `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`); the framework assumes no particular domain, and the short node `name` is just the first label of the host.
|
||||
- **Four-pillar node repositories** — every node repository follows the same shape: the pinned `commons/` submodule, node-local [proprietaries](/apex/glossary#proprietaries), the node's [compositions](/apex/glossary#compositions), and its `node.env`.
|
||||
- **Proprietary overlay** — node-local proprietaries are [overlaid](/apex/glossary#overlay) over the shared actions; commons holds only what every node shares.
|
||||
- **Tiered storage with privilege isolation** — standardized storage [tiers](/apex/glossary#tier) with per-project privilege isolation.
|
||||
- **Shared core composition** — the [core composition](/apex/glossary#core-composition) (`compositions/apex/docker-compose.yml`) is `${env}`-parameterized and included in every node's compositions.
|
||||
|
||||
## Sections
|
||||
|
||||
{{% cards %}}
|
||||
{{< card link="getting-started" title="Getting started" icon="map" subtitle="Prerequisites, bootstrap, and the first actions on a fresh node." >}}
|
||||
{{< card link="concepts" title="Concepts" icon="book-open" subtitle="Architecture, identity, the overlay, and the storage model." >}}
|
||||
{{< card link="guides" title="Guides" icon="map" subtitle="Task-oriented guides for operating and extending a node." >}}
|
||||
{{< card link="reference" title="Reference" icon="code" subtitle="Actions, the engine helper library, and exit codes." >}}
|
||||
{{< card link="troubleshooting" title="Troubleshooting" icon="shield-check" subtitle="Symptoms, diagnosis, and fixes for common failures." >}}
|
||||
{{% /cards %}}
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com)
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Concepts
|
||||
description: "Architecture, identity, actions, execution context, compositions, storage, addressing, and security."
|
||||
weight: 10
|
||||
---
|
||||
|
||||
Theoretical foundations of the apex fleet-operations framework: how a [node](/apex/glossary#node) declares its [identity](/apex/concepts/node-identity), how [actions](/apex/glossary#action) execute, and how a node organizes its services, storage, and addressing.
|
||||
|
||||
{{% cards %}}
|
||||
{{< card link="architecture" title="Architecture" icon="share" subtitle="The engine, commons, and configs layers and how they fit together." >}}
|
||||
{{< card link="node-identity" title="Node identity" icon="finger-print" subtitle="How a node declares who it is and where it belongs." >}}
|
||||
{{< card link="actions-and-overlay" title="Actions and overlay" icon="lightning-bolt" subtitle="Shared actions and node-local overrides." >}}
|
||||
{{< card link="execution-context" title="Execution context" icon="terminal" subtitle="The environment every action receives at run time." >}}
|
||||
{{< card link="compositions" title="Compositions" icon="cube" subtitle="How service stacks are declared and deployed per node." >}}
|
||||
{{< card link="tiered-storage" title="Tiered storage" icon="database" subtitle="Standardized storage classification for service data." >}}
|
||||
{{< card link="addressing" title="Addressing" icon="globe-alt" subtitle="The node subnet, anchor IPs, and the enclave convention." >}}
|
||||
{{< card link="security-model" title="Security model" icon="shield-check" subtitle="Privilege isolation and network hardening." >}}
|
||||
{{% /cards %}}
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Guides: Operating an apex fleet](/apex/guides)
|
||||
- [Reference: Commands and interfaces](/apex/reference)
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: "Actions and the overlay"
|
||||
description: "The action contract and how node-local proprietaries shadow commons actions."
|
||||
weight: 13
|
||||
---
|
||||
|
||||
Every unit of automation in apex is an [action](/apex/glossary#action): a single Python module that the [engine](/apex/glossary#engine) dispatches by name. The [overlay](/apex/glossary#overlay) lets a node's [proprietaries](/apex/glossary#proprietaries) add, override, or disable [commons](/apex/glossary#commons) actions without forking them. This page defines the action contract, the declarative argument descriptors, and the resolution rules.
|
||||
|
||||
## The action contract {#contract}
|
||||
|
||||
An action is one `.py` file under an `actions/` tree. The file path **is** the action name: `actions/<group>/<action>.py` is invoked as `apex <group>/<action> [args...]`. There is no registry to update — dropping a file into the tree publishes the action.
|
||||
|
||||
The module must expose two things:
|
||||
|
||||
- **`METADATA = Meta(...)`** → the summary and argument descriptors that drive both the help text and parsing. The descriptor system succeeds the `action_metadata()` heredoc of the bash "root" commons framework, apex's predecessor.
|
||||
- **`run(ctx, args)`** → the entrypoint. `ctx` is the engine-built execution context; `args` is the parsed `argparse` namespace.
|
||||
|
||||
```python {filename="actions/<group>/<action>.py"}
|
||||
from engine.descriptor import Meta, Arg, Flag
|
||||
|
||||
METADATA = Meta(
|
||||
summary="One-line summary shown in the action listing",
|
||||
args=[
|
||||
Arg("target", help="A required positional argument"),
|
||||
Flag("--dry-run", help="A boolean flag"),
|
||||
],
|
||||
)
|
||||
|
||||
def run(ctx, args):
|
||||
...
|
||||
return 0
|
||||
```
|
||||
|
||||
The return value of `run` becomes the process exit code. A non-integer return (including `None`) is treated as success (`0`). An unhandled exception is logged with the context prefix and exits `1`.
|
||||
|
||||
> [!NOTE]
|
||||
> `METADATA` is technically optional — a module without it is dispatched with an empty `Meta` and accepts no arguments — but every real action should declare it, since the listing and `--help` output are generated from it.
|
||||
|
||||
## Declarative arguments {#arguments}
|
||||
|
||||
`Meta.args` is a list of descriptors. Each maps to exactly one `argparse` construct:
|
||||
|
||||
| Descriptor | Maps to | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `Arg("name")` | positional argument | Required by default; `required=False` makes it an optional positional (`nargs="?"`, default `None`). |
|
||||
| `Opt("--name", default=...)` | value option | An optional flag that takes a value, with a declared default. |
|
||||
| `Flag("--name")` | boolean flag | `store_true`; present means `True`. |
|
||||
| `Rest("name")` | remainder | Greedy passthrough of all remaining argv (`argparse.REMAINDER`). |
|
||||
|
||||
The engine builds the parser as `apex <group>/<action>` and parses the remaining argv before calling `run`. `--help` is handled at parse time. A usage error — unknown flag, missing positional — prints usage and exits `64` (`EX_USAGE`) instead of argparse's default `2`.
|
||||
|
||||
## Listing without importing {#listing}
|
||||
|
||||
`apex` with no arguments (or `--help`) lists every available action. Listing never imports action modules: the engine statically scans each file with the `ast` module and extracts only the `METADATA` summary and any `DISABLED` constant. Action code therefore cannot run — or crash — at list time; a file that fails to parse is listed as `(unparseable)` rather than breaking the listing.
|
||||
|
||||
Each entry is tagged with its overlay source:
|
||||
|
||||
- no tag → a commons action,
|
||||
- `(local)` → exists only in proprietaries,
|
||||
- `(local override)` → a proprietaries action shadowing a commons one,
|
||||
- `[DISABLED: reason]` → the action declares `DISABLED`.
|
||||
|
||||
## The overlay {#overlay}
|
||||
|
||||
Action names are resolved across two trees, proprietaries first:
|
||||
|
||||
{{< filetree/container >}}
|
||||
{{< filetree/folder name="node repository" state="open" >}}
|
||||
{{< filetree/file name="node.env" >}}
|
||||
{{< filetree/folder name="commons" state="open" >}}
|
||||
{{< filetree/folder name="engine" state="closed" >}}{{< /filetree/folder >}}
|
||||
{{< filetree/folder name="actions" state="open" >}}
|
||||
{{< filetree/file name="<group>/<action>.py" >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< filetree/folder name="proprietaries" state="open" >}}
|
||||
{{< filetree/folder name="actions" state="open" >}}
|
||||
{{< filetree/file name="<group>/<action>.py" >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/container >}}
|
||||
|
||||
The engine detects this layout by its own location: when it runs from a `commons/` directory whose parent contains `node.env`, the parent is the [node](/apex/glossary#node) repository root and `proprietaries/actions/` is the overlay tree. A standalone commons checkout is its own root and has no overlay layer. Inside a node repository, [node identity](/apex/glossary#node-identity) is resolved from the host FQDN before dispatch.
|
||||
|
||||
Resolution for a given name checks `proprietaries/actions/<name>.py` first and `commons/actions/<name>.py` second; exactly one module is loaded by path. An unknown name is an error (exit `1`). The overlay supports four moves:
|
||||
|
||||
- **Add** → a file that exists only in proprietaries becomes a node-local action, listed as `(local)`.
|
||||
- **Override** → a proprietaries file at the same relative path shadows the commons implementation entirely; the commons path is recorded on the resolution as `shadowed`.
|
||||
- **Wrap** → an overriding module can delegate to the implementation it shadows via `ctx.commons.run(args)`, adding node-local behavior before or after the commons logic instead of replacing it.
|
||||
- **Disable** → a module that assigns `DISABLED = "reason"` is refused at dispatch: the engine logs the reason and exits `1`. The listing shows `[DISABLED: reason]`. A bare `DISABLED = True` also disables, with the generic reason `disabled`; an empty string does not.
|
||||
|
||||
## Exit codes {#exit-codes}
|
||||
|
||||
Actions and the engine follow a fixed exit-code convention:
|
||||
|
||||
| Code | Meaning | Typical cause |
|
||||
| --- | --- | --- |
|
||||
| `0` | success | `run` returned `0` or a non-integer. |
|
||||
| `1` | general error | Unknown action, disabled action, unhandled exception, non-integer `SystemExit`. |
|
||||
| `64` | usage error | Arguments rejected by the parser (`EX_USAGE`). |
|
||||
| `65` | data error | Malformed input data (`EX_DATAERR`), raised by engine libraries. |
|
||||
| `66` | missing input | A required input is absent (`EX_NOINPUT`), raised by engine libraries. |
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Framework architecture](/apex/concepts/architecture)
|
||||
- [Concept: The execution context](/apex/concepts/execution-context)
|
||||
- [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com)
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user