fix, feat, doc: massive build system refactorion, bug fixes and apex documentation (#19)

This commit is contained in:
deytenit authored and GitHub committed 2026-07-12 01:57:40 +03:00
1 parent e8b42c60c2
commit 91f5245823
165 files changed
+8598 -1205

No files matched your search

+12
View File
@@ -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
View File
@@ -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
+32
View File
@@ -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!"
+105
View File
@@ -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.
+45
View File
@@ -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"
}
}
+8
View File
@@ -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);
});
+17
View File
@@ -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');
});
});
+25
View File
@@ -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');
});
});
+24
View File
@@ -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');
});
});
+24
View File
@@ -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');
});
});
+24
View File
@@ -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',
};
}
+67
View File
@@ -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);
});
});
+73
View File
@@ -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;
}
+49
View File
@@ -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));
});
});
+119
View File
@@ -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,
})
);
}
}
+136
View File
@@ -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[];
}
+12
View File
@@ -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"]
}
+8
View File
@@ -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',
+52
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/dev.js';
+1
View File
@@ -0,0 +1 @@
export * from './lib/index.js';
+36
View File
@@ -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;
}
@@ -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()] }));
}
@@ -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
+4
View File
@@ -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';
+1
View File
@@ -0,0 +1 @@
export * from './core/createPage.js';
+17
View File
@@ -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"]
}
+4 -1
View File
@@ -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');
});
});
+106 -50
View File
@@ -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
+43 -52
View File
@@ -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}
+38 -28
View File
@@ -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"
+28 -19
View File
@@ -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);
+100 -129
View File
@@ -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>
);
+22 -14
View File
@@ -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');
});
});
+165 -80
View File
@@ -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 && (
+30 -11
View File
@@ -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
View File
@@ -1,2 +1,3 @@
export * from './scale';
export * from './theme';
export * from './typography';
+1
View File
@@ -0,0 +1 @@
export * from './scale';
+42
View File
@@ -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;
@@ -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');
});
});
@@ -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(' ');
}
+73 -4
View File
@@ -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);
}
+1564 -62
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -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 >}}
+37
View File
@@ -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(...)`** &rarr; 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)`** &rarr; 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 &rarr; a commons action,
- `(local)` &rarr; exists only in proprietaries,
- `(local override)` &rarr; a proprietaries action shadowing a commons one,
- `[DISABLED: reason]` &rarr; 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** &rarr; a file that exists only in proprietaries becomes a node-local action, listed as `(local)`.
- **Override** &rarr; a proprietaries file at the same relative path shadows the commons implementation entirely; the commons path is recorded on the resolution as `shadowed`.
- **Wrap** &rarr; 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** &rarr; 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