Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a20e34a37c | ||
|
|
208f6406e6
|
||
|
|
82236be7d6
|
||
|
|
7ac7c1b405
|
||
|
|
77fa576ac6
|
||
|
|
07b16d404c | ||
|
|
a103313480 | ||
|
|
51a783f93a | ||
|
|
8dba55272c | ||
|
|
6b0a893c86 | ||
|
|
575959ffa2 | ||
|
|
90bf68c4c8 | ||
|
|
cd40139eea | ||
|
|
f5e901c131 | ||
|
|
b84ef775b8 | ||
|
|
ac9c455e5a | ||
|
|
ea35a3d9f5 |
No files matched your search
@@ -320,7 +320,7 @@ Use GitHub-flavored alerts for emphasis.
|
||||
## 6. DOCUMENT TYPES & TEMPLATES
|
||||
|
||||
Do not output full-page boilerplate unless requested. Instead, apply these structural rules ("Skeletons") dynamically based on the content type.
|
||||
This is also serves as the fixed categories which should populate every project's docs: Getting Started, Guides, Recipes, Concepts, Troubleshooting.
|
||||
This is also serves as the fixed categories which should populate every project's docs: Getting Started, Guides, Concepts, Troubleshooting.
|
||||
|
||||
### 6.1 General Skeleton (All Docs)
|
||||
|
||||
@@ -349,7 +349,7 @@ This is also serves as the fixed categories which should populate every project'
|
||||
3. **Procedure:** Strict numbered steps (or `{{% steps %}}`).
|
||||
4. **Result Control:** How to verify success.
|
||||
|
||||
### 6.4 How-to / Recipe (Task-Oriented)
|
||||
### 6.4 How-to / Guide (Task-Oriented)
|
||||
|
||||
- **Goal:** Solve a specific problem.
|
||||
- **Structure:**
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/packages-ui-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -64,6 +65,8 @@ jobs:
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Rsbuild Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -76,6 +79,12 @@ jobs:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-ui:
|
||||
name: Lint - UI
|
||||
@@ -103,6 +112,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Lint UI package
|
||||
run: pnpm --filter @ermnvldmr/ui lint
|
||||
|
||||
@@ -132,6 +146,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck UI package
|
||||
run: pnpm --filter @ermnvldmr/ui typecheck
|
||||
|
||||
@@ -161,6 +180,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Test UI package
|
||||
run: pnpm --filter @ermnvldmr/ui test:coverage
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -197,6 +221,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI Storybook
|
||||
run: pnpm --filter @ermnvldmr/ui build-storybook
|
||||
|
||||
@@ -226,6 +255,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --filter @ermnvldmr/ui exec playwright install --with-deps chromium
|
||||
- name: Build UI Storybook
|
||||
@@ -266,5 +300,10 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI package
|
||||
run: pnpm --filter @ermnvldmr/ui build
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/packages-ui-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -64,6 +65,8 @@ jobs:
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Rsbuild Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -76,6 +79,12 @@ jobs:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-ui:
|
||||
name: Lint - UI
|
||||
@@ -103,6 +112,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Lint UI package
|
||||
run: pnpm --filter @ermnvldmr/ui lint
|
||||
|
||||
@@ -132,6 +146,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck UI package
|
||||
run: pnpm --filter @ermnvldmr/ui typecheck
|
||||
|
||||
@@ -161,6 +180,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Test UI package
|
||||
run: pnpm --filter @ermnvldmr/ui test:coverage
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -197,6 +221,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI Storybook
|
||||
run: pnpm --filter @ermnvldmr/ui build-storybook
|
||||
- name: Upload Storybook build
|
||||
@@ -232,6 +261,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --filter @ermnvldmr/ui exec playwright install --with-deps chromium
|
||||
- name: Download Storybook build
|
||||
@@ -275,5 +309,10 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI package
|
||||
run: pnpm --filter @ermnvldmr/ui build
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- 'services/static/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-static-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -63,12 +64,20 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-static:
|
||||
name: Lint - Static
|
||||
@@ -91,6 +100,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Lint static service
|
||||
run: pnpm --filter @ermnvldmr/static lint
|
||||
|
||||
@@ -115,6 +129,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck static service
|
||||
run: pnpm --filter @ermnvldmr/static typecheck
|
||||
|
||||
@@ -139,6 +158,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Build static service
|
||||
run: pnpm --filter @ermnvldmr/static build
|
||||
- name: Upload build artifacts
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- 'services/static/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-static-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -63,12 +64,20 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n 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
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-static:
|
||||
name: Lint - Static
|
||||
@@ -91,6 +100,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Lint static service
|
||||
run: pnpm --filter @ermnvldmr/static lint
|
||||
|
||||
@@ -115,6 +129,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck static service
|
||||
run: pnpm --filter @ermnvldmr/static typecheck
|
||||
|
||||
@@ -139,6 +158,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Build static service
|
||||
run: pnpm --filter @ermnvldmr/static build
|
||||
- name: Upload build artifacts
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- 'services/www/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-www-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -77,6 +78,14 @@ jobs:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Build i18n package
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-www:
|
||||
name: Lint - WWW
|
||||
@@ -99,6 +108,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -128,6 +142,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -157,6 +176,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -192,6 +216,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -228,6 +257,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -271,13 +305,26 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Build WWW service
|
||||
run: pnpm --filter @ermnvldmr/www build
|
||||
- name: Build WWW service (all locales)
|
||||
run: pnpm --filter @ermnvldmr/www build:all
|
||||
env:
|
||||
RSDOCTOR: 'true'
|
||||
- name: Upload Rsdoctor report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsdoctor-report
|
||||
path: services/www/rsdoctor/
|
||||
retention-days: 30
|
||||
|
||||
release-docker:
|
||||
name: Release - Docker Image
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- 'services/www/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-www-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -77,6 +78,14 @@ jobs:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Build i18n package
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-www:
|
||||
name: Lint - WWW
|
||||
@@ -99,6 +108,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -128,6 +142,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -157,6 +176,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -192,6 +216,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -228,6 +257,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -271,10 +305,23 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Build WWW service
|
||||
run: pnpm --filter @ermnvldmr/www build
|
||||
- name: Build WWW service (all locales)
|
||||
run: pnpm --filter @ermnvldmr/www build:all
|
||||
env:
|
||||
RSDOCTOR: 'true'
|
||||
- name: Upload Rsdoctor report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsdoctor-report-${{ github.run_id }}
|
||||
path: services/www/rsdoctor/
|
||||
retention-days: 7
|
||||
@@ -54,6 +54,8 @@ jobs:
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -61,6 +63,7 @@ jobs:
|
||||
path: |
|
||||
packages/rsbuild-config/dist
|
||||
packages/storybook-config/dist
|
||||
packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-www:
|
||||
|
||||
@@ -60,6 +60,8 @@ jobs:
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -67,6 +69,7 @@ jobs:
|
||||
path: |
|
||||
packages/rsbuild-config/dist
|
||||
packages/storybook-config/dist
|
||||
packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-www:
|
||||
|
||||
@@ -30,3 +30,4 @@ playwright-report/
|
||||
|
||||
# agents plans and designs
|
||||
docs/plans/
|
||||
docs/superpowers/
|
||||
@@ -33,7 +33,7 @@
|
||||
│ ├── stl/ # Standard Type Library (utilities)
|
||||
│ └── ui/ # Shared React component library
|
||||
├── services/ # Independent web services
|
||||
│ ├── www/ # Main website (Astro + React)
|
||||
│ ├── www/ # Main website (Rsbuild + React)
|
||||
│ └── docs/ # Documentation site (Hugo)
|
||||
├── .github/workflows/ # CI/CD automation
|
||||
├── pnpm-workspace.yaml # Workspace configuration
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
#### Configuration Packages (`packages/`)
|
||||
|
||||
- **eslint-config**: Shared ESLint rules for TypeScript, React, and Astro
|
||||
- **eslint-config**: Shared ESLint rules for TypeScript and React
|
||||
- **rsbuild-config**: Shared Rsbuild and Rspack configurations for library and service builds
|
||||
- **storybook-config**: Shared Storybook setup and configurations
|
||||
|
||||
@@ -52,10 +52,12 @@
|
||||
|
||||
- **stl** (Standard Type Library): Common utilities, types, and helper functions
|
||||
- **ui**: React component library with design system and Storybook stories
|
||||
- **i18n**: Internationalization utilities
|
||||
|
||||
#### Service Packages (`services/`)
|
||||
|
||||
- **www**: Main website built with Astro.js, React, and Tailwind CSS
|
||||
- **www**: Main website built with Rsbuild, React, and Tailwind CSS
|
||||
- **static**: Static landing pages service built with Rsbuild and React
|
||||
- **docs**: Documentation site built with Hugo and Hextra theme
|
||||
|
||||
### Key Directory Rules
|
||||
@@ -140,7 +142,9 @@ All packages use the `@ermnvldmr/` namespace:
|
||||
- `@ermnvldmr/storybook-config` - Shared Storybook configurations
|
||||
- `@ermnvldmr/stl` - Standard Type Library
|
||||
- `@ermnvldmr/ui` - Component library
|
||||
- `@ermnvldmr/i18n` - Internationalization utilities
|
||||
- `@ermnvldmr/www` - Main website service
|
||||
- `@ermnvldmr/static` - Static landing pages service
|
||||
- `@ermnvldmr/docs` - Documentation service
|
||||
|
||||
### TypeScript Guidelines
|
||||
@@ -175,6 +179,15 @@ export function cn(...inputs: ClassValue[]): string {
|
||||
}
|
||||
```
|
||||
|
||||
### Internationalization (i18n) Guidelines
|
||||
|
||||
- **Package**: Use the `@ermnvldmr/i18n` package for all translations across services.
|
||||
- **Keyset Pattern**: Create `.i18n.ts` files alongside components that require translation.
|
||||
- **Russian Translation Style**: Embrace the Russian language fully. Prefer phonetic transliteration/transcription for global brand names and descriptive terms over keeping them in English.
|
||||
- **Brands**: 'GitHub' → 'ГитХаб', 'Instagram' → 'Инстаграм', 'Telegram' → 'Телеграм'.
|
||||
- **Descriptive**: 'Email' → 'Электропочта'.
|
||||
- **Source of Truth**: The English locale (`en`) serves as the primary reference for keys and structure.
|
||||
|
||||
### Component Development Patterns
|
||||
|
||||
#### UI Package Components
|
||||
@@ -306,7 +319,7 @@ Each configuration package provides standardized setups:
|
||||
#### ESLint Configuration
|
||||
|
||||
```javascript
|
||||
// Provides: base, react, astro configurations
|
||||
// Provides: base, react configurations
|
||||
import { baseConfig } from '@ermnvldmr/eslint-config/dev';
|
||||
```
|
||||
|
||||
@@ -421,3 +434,11 @@ describe('components/paper/Button/Button', () => {
|
||||
---
|
||||
|
||||
_This guide serves as the architectural foundation for the monorepo. Focus on these stable patterns and structures rather than specific implementation details that may change over time._
|
||||
|
||||
## 🤖 AI Agent Best Practices
|
||||
|
||||
The following are explicit best practices and preferences intended for AI tools and agents operating within this repository:
|
||||
|
||||
- **Build System Preference**: Rsbuild and Rspack are the standard and strongly preferred build tools for all projects in this monorepo due to their Rust-based architecture and performance. Do not introduce Vite, Astro, or Webpack unless strictly necessary.
|
||||
- **Framework Preference**: The monorepo has migrated away from Astro in favor of React + Rsbuild (with MDX support).
|
||||
- **Content Sites**: Rspress is the preferred infrastructure for any new content-heavy or documentation sites.
|
||||
@@ -23,13 +23,15 @@ Composite (see [Copyright & License](#copyright--license))
|
||||
|
||||
#### Services
|
||||
|
||||
- **[www](./services/www)**: Main website, built with Astro and React.
|
||||
- **[www](./services/www)**: Main website, built with Rsbuild, React, and MDX.
|
||||
- **[static](./services/static)**: Static landing pages, built with Rsbuild and React.
|
||||
- **[docs](./services/docs)**: Documentation site, built with Hugo and the Hextra theme.
|
||||
|
||||
#### Packages
|
||||
|
||||
- **[@ermnvldmr/ui](./packages/ui)**: Shared React component library and design system.
|
||||
- **[@ermnvldmr/stl](./packages/stl)**: Standard Template Library – common utilities and reusable logic.
|
||||
- **[@ermnvldmr/i18n](./packages/i18n)**: Internationalization utilities.
|
||||
- **[@ermnvldmr/eslint-config](./packages/eslint-config)**: Shared linting configurations.
|
||||
- **[@ermnvldmr/rsbuild-config](./packages/rsbuild-config)**: Shared build configurations (Rsbuild & Rspack).
|
||||
- **[@ermnvldmr/storybook-config](./packages/storybook-config)**: Shared Storybook configurations.
|
||||
|
||||
@@ -12,20 +12,38 @@ server {
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# Clean URLs (remove .html from URI)
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html /index.html;
|
||||
# Root redirect: detect browser language, fallback to /en/
|
||||
location = / {
|
||||
set $locale "en";
|
||||
if ($http_accept_language ~* "(^|,\s*)ru") {
|
||||
set $locale "ru";
|
||||
}
|
||||
return 302 /$locale/;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# Cache hashed static assets (JS, CSS, fonts) — matched before locale prefix blocks
|
||||
# root is inherited from server block, so /en/static/js/file.js resolves correctly
|
||||
location ~* \.(js|css|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache images
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
# English locale — SPA fallback for non-asset routes
|
||||
location /en/ {
|
||||
try_files $uri $uri/ $uri.html /en/index.html;
|
||||
}
|
||||
|
||||
# Russian locale — SPA fallback for non-asset routes
|
||||
location /ru/ {
|
||||
try_files $uri $uri/ $uri.html /ru/index.html;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /en/index.html;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ COPY packages/rsbuild-config/package.json ./packages/rsbuild-config/
|
||||
COPY packages/stl/package.json ./packages/stl/
|
||||
COPY packages/storybook-config/package.json ./packages/storybook-config/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/i18n/package.json ./packages/i18n/
|
||||
COPY services/www/package.json ./services/www/
|
||||
|
||||
# Install dependencies
|
||||
@@ -27,10 +28,11 @@ COPY . .
|
||||
# Build dependencies
|
||||
RUN pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
RUN pnpm --filter @ermnvldmr/stl build
|
||||
RUN pnpm --filter @ermnvldmr/i18n build
|
||||
RUN pnpm --filter @ermnvldmr/ui build
|
||||
|
||||
# Build the website
|
||||
RUN pnpm --filter @ermnvldmr/www build
|
||||
# Build the website (all locales)
|
||||
RUN pnpm --filter @ermnvldmr/www build:all
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM nginx:alpine
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-check
|
||||
import { baseConfig } from '@ermnvldmr/eslint-config/dev';
|
||||
import eslintPluginAstro from 'eslint-plugin-astro';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
@@ -22,20 +21,10 @@ export default [
|
||||
'**/coverage/**',
|
||||
'**/storybook-static/**',
|
||||
'**/.next/**',
|
||||
'**/.astro/**',
|
||||
'**/*.d.ts',
|
||||
],
|
||||
},
|
||||
|
||||
// Astro
|
||||
...eslintPluginAstro.configs.recommended,
|
||||
{
|
||||
files: ['services/www/**/*.astro', 'services/static/**/*.astro'],
|
||||
rules: {
|
||||
// Astro specific rules can go here
|
||||
},
|
||||
},
|
||||
|
||||
// Storybook - recommended configuration for flat config
|
||||
...storybook.configs['flat/recommended'],
|
||||
|
||||
|
||||
+2
-1
@@ -43,7 +43,8 @@
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"test": "pnpm -r test",
|
||||
"test:coverage": "pnpm -r test:coverage",
|
||||
"clean": "pnpm -r clean && rm -rf node_modules"
|
||||
"clean": "pnpm -r clean && rm -rf node_modules",
|
||||
"build:deps": "pnpm -r --filter='./packages/*-config' build && pnpm --filter='./packages/i18n' build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"globals": "^16.0.0",
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsdoc": "^61.5.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@ermnvldmr/i18n",
|
||||
"version": "1.0.0",
|
||||
"description": "Internationalization utilities for ermnvldmr projects",
|
||||
"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"
|
||||
},
|
||||
"./testing": {
|
||||
"import": "./dist/testing.js",
|
||||
"types": "./dist/testing.d.ts",
|
||||
"default": "./src/testing.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rsbuild build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist coverage"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ermnvldmr/eslint-config": "workspace:*",
|
||||
"@ermnvldmr/rsbuild-config": "workspace:*",
|
||||
"@rsbuild/core": "^1.2.11",
|
||||
"@types/react": "^19.1.2",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"react": "^19.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { definePackageConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
|
||||
export default definePackageConfig({
|
||||
source: {
|
||||
entry: {
|
||||
index: './src/index.ts',
|
||||
dev: './src/dev.ts',
|
||||
testing: './src/testing.ts',
|
||||
},
|
||||
alias: { '@': './src' },
|
||||
},
|
||||
output: {
|
||||
target: 'web',
|
||||
distPath: { root: 'dist', js: '' },
|
||||
filename: { js: '[name].js' },
|
||||
cleanDistPath: true,
|
||||
},
|
||||
tools: {
|
||||
htmlPlugin: false,
|
||||
rspack: {
|
||||
output: { library: { type: 'module' } },
|
||||
experiments: { outputModule: true },
|
||||
externals: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
|
||||
externalsType: 'module',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { DEFAULT_LOCALE, isLocale, type Locale } from './lib/locale.js';
|
||||
|
||||
import type { RsbuildConfig, RsbuildPlugin } from '@rsbuild/core';
|
||||
|
||||
/**
|
||||
* Returns a partial Rsbuild config that enables locale-aware builds.
|
||||
*
|
||||
* The locale MDX replacement is provided as an Rsbuild plugin (rather than a
|
||||
* `tools.rspack` function) so that `mergeConfig` can safely concatenate it
|
||||
* with the service's own `plugins` array. A `tools.rspack` function would be
|
||||
* silently dropped by `mergeConfig` when the service config also defines
|
||||
* `tools.rspack` as a plain object.
|
||||
*
|
||||
* Designed to be merged with defineServiceConfig() in each service's rsbuild.config.ts:
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { localeRsbuildConfig } from '@ermnvldmr/i18n/dev';
|
||||
* import { defineServiceConfig, mergeConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
*
|
||||
* export default defineServiceConfig(mergeConfig(localeRsbuildConfig(), { ... }));
|
||||
* ```
|
||||
*/
|
||||
export function localeRsbuildConfig(): RsbuildConfig {
|
||||
const rawLocale = process.env.LOCALE ?? '';
|
||||
const locale: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
|
||||
|
||||
const localePlugins: RsbuildPlugin[] = [];
|
||||
|
||||
if (locale !== DEFAULT_LOCALE) {
|
||||
localePlugins.push({
|
||||
name: 'ermnvldmr:locale-mdx-replacement',
|
||||
setup(api) {
|
||||
// Resolve .en.mdx imports to the locale-specific file when LOCALE != 'en'.
|
||||
// The canonical import is always `.en.mdx`; other locales are resolved here.
|
||||
api.modifyRspackConfig((config, { rspack }) => {
|
||||
config.plugins = [
|
||||
...config.plugins,
|
||||
new rspack.NormalModuleReplacementPlugin(
|
||||
/\.en\.mdx$/,
|
||||
(resource: { request: string }) => {
|
||||
resource.request = resource.request.replace(/\.en\.mdx$/, `.${locale}.mdx`);
|
||||
}
|
||||
),
|
||||
];
|
||||
return config;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
source: {
|
||||
define: {
|
||||
'process.env.LOCALE': JSON.stringify(locale),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// Dev server serves pages under the locale base so localePath() links
|
||||
// resolve correctly in dev without any NODE_ENV branching.
|
||||
base: `/${locale}`,
|
||||
},
|
||||
output: {
|
||||
distPath: {
|
||||
root: `dist/${locale}`,
|
||||
},
|
||||
assetPrefix: `/${locale}/`,
|
||||
},
|
||||
plugins: localePlugins,
|
||||
};
|
||||
}
|
||||
|
||||
export { LOCALES, DEFAULT_LOCALE, type Locale } from './lib/locale.js';
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './lib/locale.js';
|
||||
export * from './lib/pluralize.js';
|
||||
export * from './lib/keyset.js';
|
||||
export * from './lib/path.js';
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { createKeyset } from './keyset.js';
|
||||
|
||||
const { t, tRaw } = createKeyset({
|
||||
greeting: { en: 'Hello', ru: 'Привет' },
|
||||
'count {value}': { en: 'count {value}', ru: 'счёт {value}' },
|
||||
items: {
|
||||
en: { none: 'no items', one: 'one item', some: '{count} items', many: '{count} items' },
|
||||
ru: {
|
||||
none: 'нет элементов',
|
||||
one: 'один элемент',
|
||||
some: '{count} элемента',
|
||||
many: '{count} элементов',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('t', () => {
|
||||
it('returns plain translation', () => expect(t('greeting')).toBe('Hello'));
|
||||
it('interpolates {value}', () => expect(t('count {value}', { value: '42' })).toBe('count 42'));
|
||||
it('pluralizes none', () => expect(t('items', { count: 0 })).toBe('no items'));
|
||||
it('pluralizes one', () => expect(t('items', { count: 1 })).toBe('one item'));
|
||||
it('pluralizes many with interpolation', () => expect(t('items', { count: 5 })).toBe('5 items'));
|
||||
});
|
||||
|
||||
describe('tRaw', () => {
|
||||
it('returns string node for plain key', () => expect(tRaw('greeting')).toBe('Hello'));
|
||||
it('interpolates ReactNode', () => {
|
||||
const node = tRaw('count {value}', { value: React.createElement('strong', null, '42') });
|
||||
expect(Array.isArray(node)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { LOCALE } from './locale.js';
|
||||
import { pluralize, type PluralForms } from './pluralize.js';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** A translation entry with a plain string per locale. */
|
||||
interface PlainTranslation {
|
||||
en: string;
|
||||
ru: string;
|
||||
}
|
||||
|
||||
/** A translation entry with plural forms per locale. */
|
||||
interface PluralTranslation {
|
||||
en: PluralForms;
|
||||
ru: PluralForms;
|
||||
}
|
||||
|
||||
/** Union of all supported translation shapes. */
|
||||
type Translation = PlainTranslation | PluralTranslation;
|
||||
|
||||
/** A map of translation keys to their translation entries. */
|
||||
type Keyset = Record<string, Translation>;
|
||||
|
||||
/**
|
||||
* Returns `true` when the translation entry uses plural forms.
|
||||
*
|
||||
* @param t - The translation entry to inspect.
|
||||
* @returns Type predicate narrowing `t` to {@link PluralTranslation}.
|
||||
* @example
|
||||
* isPluralTranslation({ en: 'hello', ru: 'привет' }) // → false
|
||||
*/
|
||||
function isPluralTranslation(t: Translation): t is PluralTranslation {
|
||||
return typeof t.en === 'object';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces `{key}` placeholders in `template` with values from `params`.
|
||||
*
|
||||
* @param template - String containing `{key}` placeholders.
|
||||
* @param params - Map of placeholder names to string/number values.
|
||||
* @returns The interpolated string.
|
||||
* @example
|
||||
* interpolate('Hello {name}!', { name: 'World' }) // → 'Hello World!'
|
||||
*/
|
||||
function interpolate(template: string, params: Record<string, string | number>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) =>
|
||||
key in params ? String(params[key]) : `{${key}}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `template` on `{key}` placeholders and substitutes ReactNode values.
|
||||
*
|
||||
* @param template - String containing `{key}` placeholders.
|
||||
* @param params - Map of placeholder names to ReactNode values.
|
||||
* @returns A single string when all parts are strings, otherwise a ReactNode array.
|
||||
* @example
|
||||
* interpolateRaw('Click {link}', { link: <a href="/">here</a> })
|
||||
*/
|
||||
function interpolateRaw(template: string, params: Record<string, ReactNode>): ReactNode {
|
||||
const parts = template.split(/(\{[^}]+\})/g);
|
||||
const result = parts.map((part) => {
|
||||
const match = /^\{(\w+)\}$/.exec(part);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
return key in params ? params[key] : part;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
if (result.every((p): p is string => typeof p === 'string')) {
|
||||
return result.join('');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typed `t` and `tRaw` pair bound to `keyset`.
|
||||
*
|
||||
* @param keyset - An object mapping translation keys to their locale entries.
|
||||
* @returns `{ t, tRaw }` translation helpers.
|
||||
* @example
|
||||
* const { t } = createKeyset({ greeting: { en: 'Hello', ru: 'Привет' } });
|
||||
* t('greeting') // → 'Hello' (in EN build)
|
||||
*/
|
||||
export function createKeyset<const K extends Keyset>(keyset: K) {
|
||||
/** String union of all keys defined in `keyset`. */
|
||||
type Keys = keyof K & string;
|
||||
|
||||
/**
|
||||
* Returns the translated string for `key`, with optional interpolation and pluralisation.
|
||||
*
|
||||
* @param key - A key defined in the keyset.
|
||||
* @param params - Optional interpolation/pluralisation params.
|
||||
* @returns The translated string for the current build locale.
|
||||
* @example
|
||||
* t('greeting') // → 'Hello'
|
||||
* t('items {count}', { count: 3 }) // → '3 items'
|
||||
*/
|
||||
function t(key: Keys, params?: Record<string, string | number>): string {
|
||||
const entry = keyset[key];
|
||||
|
||||
if (isPluralTranslation(entry)) {
|
||||
const forms = entry[LOCALE];
|
||||
const count = typeof params?.count === 'number' ? params.count : 0;
|
||||
const template = pluralize(count, forms);
|
||||
return params ? interpolate(template, params) : template;
|
||||
}
|
||||
|
||||
const template = entry[LOCALE];
|
||||
return params ? interpolate(template, params) : template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translated ReactNode for `key`, allowing ReactNode interpolation values.
|
||||
*
|
||||
* @param key - A key defined in the keyset.
|
||||
* @param params - Optional interpolation params; values may be any ReactNode.
|
||||
* @returns The translated string or ReactNode array for the current build locale.
|
||||
* @example
|
||||
* tRaw('click {link}', { link: <a href="/">here</a> })
|
||||
*/
|
||||
function tRaw(key: Keys, params?: Record<string, ReactNode>): ReactNode {
|
||||
const entry = keyset[key];
|
||||
|
||||
if (isPluralTranslation(entry)) {
|
||||
const forms = entry[LOCALE];
|
||||
const countParam = params?.count;
|
||||
const count = typeof countParam === 'number' ? countParam : 0;
|
||||
const template = pluralize(count, forms);
|
||||
return params ? interpolateRaw(template, params) : template;
|
||||
}
|
||||
|
||||
const template = entry[LOCALE];
|
||||
return params ? interpolateRaw(template, params) : template;
|
||||
}
|
||||
|
||||
return { t, tRaw };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** The supported locale identifiers. */
|
||||
export type Locale = 'en' | 'ru';
|
||||
|
||||
export const LOCALES: readonly Locale[] = ['en', 'ru'] as const;
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en';
|
||||
|
||||
// Widened once so inclusion checks need no type assertions.
|
||||
const LOCALES_STRINGS: readonly string[] = LOCALES;
|
||||
|
||||
/**
|
||||
* Returns `true` when `value` is a known {@link Locale} identifier.
|
||||
*
|
||||
* @param value - The string to test.
|
||||
* @returns Type predicate narrowing `value` to `Locale`.
|
||||
* @example
|
||||
* isLocale('en') // → true
|
||||
* isLocale('fr') // → false
|
||||
*/
|
||||
export function isLocale(value: string): value is Locale {
|
||||
return LOCALES_STRINGS.includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active locale for the current build.
|
||||
* Injected at compile time via source.define → process.env.LOCALE.
|
||||
* Falls back to DEFAULT_LOCALE in non-locale-aware builds (tests, Storybook).
|
||||
*/
|
||||
const rawLocale = process.env.LOCALE ?? '';
|
||||
export const LOCALE: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
|
||||
|
||||
/**
|
||||
* The other (non-active) locale for the current build.
|
||||
* Constant-folded at build time.
|
||||
*/
|
||||
export const OTHER_LOCALE: Locale = LOCALES.find((l) => l !== LOCALE) ?? DEFAULT_LOCALE;
|
||||
|
||||
/**
|
||||
* Human-readable label for switching to the other locale,
|
||||
* expressed in the CURRENT locale's language.
|
||||
* - EN build → "In Russian"
|
||||
* - RU build → "На Английском"
|
||||
*/
|
||||
const SWITCH_LABELS: Record<Locale, Partial<Record<Locale, string>>> = {
|
||||
en: { ru: 'In Russian' },
|
||||
ru: { en: 'На Английском' },
|
||||
};
|
||||
|
||||
export const OTHER_LOCALE_LABEL: string =
|
||||
SWITCH_LABELS[LOCALE][OTHER_LOCALE] ?? OTHER_LOCALE.toUpperCase();
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { localePath, localeHref } from './path.js';
|
||||
|
||||
describe('localePath', () => {
|
||||
it('prepends LOCALE to absolute path', () =>
|
||||
expect(localePath('/articles')).toBe('/en/articles'));
|
||||
it('handles root path', () => expect(localePath('/')).toBe('/en/'));
|
||||
it('handles nested path', () =>
|
||||
expect(localePath('/articles/2026/test')).toBe('/en/articles/2026/test'));
|
||||
});
|
||||
|
||||
describe('localeHref', () => {
|
||||
it('replaces locale prefix in URL', () =>
|
||||
expect(localeHref('/en/articles', 'ru')).toBe('/ru/articles'));
|
||||
it('replaces locale at root', () => expect(localeHref('/en/', 'ru')).toBe('/ru/'));
|
||||
it('falls back to path if no prefix found', () =>
|
||||
expect(localeHref('/articles', 'ru')).toBe('/ru/articles'));
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { LOCALE, LOCALES, type Locale } from './locale.js';
|
||||
|
||||
/**
|
||||
* Resolves any href value to its locale-aware form.
|
||||
*
|
||||
* Only absolute internal paths (starting with `/`) are rewritten; everything else
|
||||
* (external URLs, mailto/tel, relative paths, hash anchors) passes through unchanged.
|
||||
* The function is idempotent: paths already carrying any known locale prefix are returned as-is.
|
||||
*
|
||||
* Works identically in dev and production: the dev server is configured with
|
||||
* `server.base = /${LOCALE}` via `localeRsbuildConfig`.
|
||||
*
|
||||
* @param href - The href value to resolve.
|
||||
* @returns The locale-prefixed href, or the original value when no rewrite is needed.
|
||||
* @example
|
||||
* // LOCALE = 'en'
|
||||
* localePath('/articles') // → '/en/articles'
|
||||
* localePath('/') // → '/en/'
|
||||
* localePath('/en/articles') // → '/en/articles' (idempotent)
|
||||
* localePath('https://github.com') // → 'https://github.com' (external)
|
||||
* localePath('mailto:a@b.com') // → 'mailto:a@b.com'
|
||||
* localePath('#section') // → '#section'
|
||||
*/
|
||||
export function localePath(href: string): string;
|
||||
export function localePath(href: null): null;
|
||||
export function localePath(href: undefined): undefined;
|
||||
export function localePath(href: string | undefined): string | undefined;
|
||||
export function localePath(href: string | null): string | null;
|
||||
export function localePath(href: string | null | undefined): string | null | undefined;
|
||||
export function localePath(href: string | null | undefined): string | null | undefined {
|
||||
if (href == null) return href;
|
||||
if (!href.startsWith('/')) return href;
|
||||
if (LOCALES.some((l) => href === `/${l}` || href === `/${l}/` || href.startsWith(`/${l}/`))) {
|
||||
return href;
|
||||
}
|
||||
if (href === '/') return `/${LOCALE}/`;
|
||||
return `/${LOCALE}${href}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a URL for a specific locale given the current locale-prefixed URL.
|
||||
*
|
||||
* @param currentPath - The current page path, e.g. `/en/articles`.
|
||||
* @param locale - The target locale.
|
||||
* @returns The equivalent path for `locale`, e.g. `/ru/articles`.
|
||||
* @example
|
||||
* localeHref('/en/articles', 'ru') // → '/ru/articles'
|
||||
* localeHref('/en/', 'ru') // → '/ru/'
|
||||
*/
|
||||
export function localeHref(currentPath: string, locale: Locale): string {
|
||||
const localePrefix = new RegExp(`^\\/(${LOCALES.join('|')})(.*)`);
|
||||
const match = localePrefix.exec(currentPath);
|
||||
if (match) {
|
||||
return `/${locale}${match[2] || '/'}`;
|
||||
}
|
||||
return `/${locale}${currentPath}`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { pluralize } from './pluralize.js';
|
||||
|
||||
const forms = { none: 'none', one: 'one', some: 'some', many: 'many' };
|
||||
|
||||
describe('pluralize', () => {
|
||||
it('returns none for 0', () => expect(pluralize(0, forms)).toBe('none'));
|
||||
it('returns one for 1', () => expect(pluralize(1, forms)).toBe('one'));
|
||||
it('returns some for 2', () => expect(pluralize(2, forms)).toBe('some'));
|
||||
it('returns some for 4', () => expect(pluralize(4, forms)).toBe('some'));
|
||||
it('returns many for 5', () => expect(pluralize(5, forms)).toBe('many'));
|
||||
it('returns many for 11', () => expect(pluralize(11, forms)).toBe('many'));
|
||||
it('returns many for 12', () => expect(pluralize(12, forms)).toBe('many'));
|
||||
it('returns many for 100', () => expect(pluralize(100, forms)).toBe('many'));
|
||||
it('returns one for 21', () => expect(pluralize(21, forms)).toBe('one'));
|
||||
it('returns some for 22', () => expect(pluralize(22, forms)).toBe('some'));
|
||||
it('returns many for 111', () => expect(pluralize(111, forms)).toBe('many'));
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/** The four plural categories used across EN and RU grammar rules. */
|
||||
export interface PluralForms {
|
||||
none: string;
|
||||
one: string;
|
||||
some: string;
|
||||
many: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the correct plural form for the given count.
|
||||
* Covers both English (none/one/many) and Russian (none/one/some/many) rules.
|
||||
*
|
||||
* @param count - The numeric value to pluralise.
|
||||
* @param forms - The set of plural forms to select from.
|
||||
* @returns The appropriate plural form string.
|
||||
* @example
|
||||
* const forms = { none: 'no items', one: 'one item', some: '{n} items', many: '{n} items' };
|
||||
* pluralize(0, forms) // → 'no items'
|
||||
* pluralize(1, forms) // → 'one item'
|
||||
* pluralize(3, forms) // → '{n} items' (some)
|
||||
* pluralize(21, forms) // → 'one item' (Russian rule: 21 ends in 1)
|
||||
*/
|
||||
export function pluralize(count: number, forms: PluralForms): string {
|
||||
if (count === 0) return forms.none;
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return forms.one;
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return forms.some;
|
||||
return forms.many;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LOCALE, DEFAULT_LOCALE, isLocale } from './lib/locale.js';
|
||||
export { localePath, localeHref } from './lib/path.js';
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -12,8 +12,10 @@
|
||||
"dependencies": {
|
||||
"@rsbuild/plugin-react": "^1.1.1",
|
||||
"@rsbuild/plugin-type-check": "^1.1.1",
|
||||
"critters": "^0.0.25",
|
||||
"fast-glob": "^3.3.3",
|
||||
"jsdom": "^28.0.0",
|
||||
"react": "^19.1.0",
|
||||
"rsbuild-plugin-dts": "^0.19.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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).
|
||||
@@ -21,7 +22,7 @@ import { mergeConfig } from '../utils/merge.js';
|
||||
*/
|
||||
export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
|
||||
const defaultConfig: RsbuildConfig = {
|
||||
plugins: [pluginReact(), pluginTypeCheck()],
|
||||
plugins: [pluginReact(), pluginTypeCheck(), ssgPlugin()],
|
||||
output: {
|
||||
distPath: {
|
||||
font: 'static/font',
|
||||
@@ -35,28 +36,6 @@ export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
|
||||
meta: {
|
||||
description: 'Thoughts and creations of mine',
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
tag: 'link',
|
||||
attrs: {
|
||||
rel: 'preload',
|
||||
as: 'font',
|
||||
type: 'font/woff2',
|
||||
href: '/static/font/lato-400.woff2',
|
||||
crossorigin: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'link',
|
||||
attrs: {
|
||||
rel: 'preload',
|
||||
as: 'font',
|
||||
type: 'font/woff2',
|
||||
href: '/static/font/lato-700.woff2',
|
||||
crossorigin: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,184 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
import React from 'react';
|
||||
import type { RsbuildPlugin } from '@rsbuild/core';
|
||||
|
||||
/**
|
||||
* A minimal Rsbuild plugin for build-time Static Site Generation.
|
||||
* Placeholder for future extension.
|
||||
* Rsbuild plugin for build-time Static Site Generation.
|
||||
*
|
||||
* After the web build completes:
|
||||
* 1. Builds each entry as a Node.js CJS bundle (dist-ssr/)
|
||||
* 2. Renders each entry with react-dom/server renderToString
|
||||
* 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/
|
||||
*/
|
||||
export function ssgPlugin(): RsbuildPlugin {
|
||||
return {
|
||||
name: 'ermnvldmr:ssg-plugin',
|
||||
setup(api) {
|
||||
api.onAfterBuild(async ({ stats }) => {
|
||||
if (!stats) return;
|
||||
console.log('[ssg-plugin] Build finished.');
|
||||
if (!stats) {
|
||||
console.warn('[ssg-plugin] No stats available, skipping SSG.');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = api.getRsbuildConfig();
|
||||
const rawEntries = config.source?.entry;
|
||||
|
||||
if (!rawEntries || typeof rawEntries !== 'object' || Array.isArray(rawEntries)) {
|
||||
console.warn('[ssg-plugin] source.entry is not an object map, skipping SSG.');
|
||||
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) {
|
||||
const imp = (value as { import: string | string[] }).import;
|
||||
entries[name] = Array.isArray(imp) ? imp[0] : imp;
|
||||
}
|
||||
}
|
||||
|
||||
const entryNames = Object.keys(entries);
|
||||
if (entryNames.length === 0) {
|
||||
console.warn('[ssg-plugin] No entries to process, skipping SSG.');
|
||||
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 =
|
||||
(api.context as { distPath?: string }).distPath ??
|
||||
path.join(
|
||||
api.context.rootPath,
|
||||
(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;
|
||||
const name = (p as { name?: string }).name;
|
||||
return name !== SSG_PLUGIN_NAME && name !== TYPE_CHECK_PLUGIN_NAME;
|
||||
});
|
||||
|
||||
const ssrBuild = await createRsbuild({
|
||||
rsbuildConfig: {
|
||||
plugins: ssrPlugins,
|
||||
source: { entry: entries },
|
||||
tools: config.tools,
|
||||
output: {
|
||||
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',
|
||||
'react/jsx-runtime': 'commonjs react/jsx-runtime',
|
||||
'react/jsx-dev-runtime': 'commonjs react/jsx-dev-runtime',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const Critters = _require('critters') as new (opts?: {
|
||||
path?: string;
|
||||
publicPath?: string;
|
||||
preload?: string;
|
||||
inlineFonts?: boolean;
|
||||
pruneSource?: boolean;
|
||||
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 =
|
||||
(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)
|
||||
});
|
||||
|
||||
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];
|
||||
|
||||
const mod = _require(ssrBundlePath) as { default?: React.ComponentType };
|
||||
const Component = mod.default;
|
||||
|
||||
if (!Component) {
|
||||
console.warn(`[ssg-plugin] Entry "${entryName}" has no default export — skipping.`);
|
||||
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>/,
|
||||
`<div id="root">${renderedHtml}</div>`
|
||||
);
|
||||
|
||||
if (injected === source) {
|
||||
console.warn(
|
||||
`[ssg-plugin] Could not find <div id="root"> in ${webHtmlPath} — injection skipped.`
|
||||
);
|
||||
}
|
||||
|
||||
// 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="[^"]*"/,
|
||||
`<html$1 lang="${locale}"`
|
||||
);
|
||||
|
||||
await fs.writeFile(webHtmlPath, localized, 'utf8');
|
||||
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.');
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './config/index.js';
|
||||
export { mergeConfig } from './utils/merge.js';
|
||||
@@ -1,5 +1,18 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { extendTailwindMerge } from 'tailwind-merge';
|
||||
|
||||
const RB_TYPES = ['display', 'headline', 'title', 'body', 'label'] as const;
|
||||
const RB_SIZES = ['l', 'm', 's'] as const;
|
||||
|
||||
const rbFontSizeClasses = RB_TYPES.flatMap((type) => RB_SIZES.map((size) => `${type}-${size}`));
|
||||
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
'font-size': [{ 'text-rb': rbFontSizeClasses }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Combines and merges CSS class names using clsx and tailwind-merge.
|
||||
@@ -7,6 +20,10 @@ import { twMerge } from 'tailwind-merge';
|
||||
* This utility function combines the power of clsx for conditional class handling
|
||||
* with tailwind-merge for proper Tailwind CSS class deduplication and conflict resolution.
|
||||
*
|
||||
* Tailwind-merge is configured to recognise `text-rb-{type}-{size}` classes
|
||||
* (e.g. `text-rb-body-m`) as font-size utilities so they do not conflict with
|
||||
* text-color classes such as `text-[var(--rb-text)]`.
|
||||
*
|
||||
* @param inputs - Class values to combine (strings, objects, arrays, etc.)
|
||||
* @returns Merged class string with conflicts resolved
|
||||
*
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type React from 'react';
|
||||
|
||||
/**
|
||||
* Safely casts a React ref to a mutable ref object.
|
||||
* This is a controlled escape hatch for situations where TypeScript's
|
||||
* built-in Ref types are too restrictive (e.g., when syncing refs manually).
|
||||
*
|
||||
* @param ref - The ref to cast
|
||||
* @returns The casted mutable ref
|
||||
* @example
|
||||
* ```tsx
|
||||
* const mutableRef = castMutableRef<HTMLDivElement>(ref);
|
||||
* mutableRef.current = someElement;
|
||||
* ```
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function castMutableRef<T>(ref: any): React.MutableRefObject<T | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return ref as React.MutableRefObject<T | null>;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export { castRef } from './castRef/castRef';
|
||||
export { castMutableRef } from './castRef/castMutableRef';
|
||||
export { genericMemo } from './genericMemo/genericMemo';
|
||||
export { useResizeObserver } from './useResizeObserver/useResizeObserver';
|
||||
export { useIntersectionObserver } from './useIntersectionObserver/useIntersectionObserver';
|
||||
export { useScroll } from './useScroll/useScroll';
|
||||
export { useLocalStorage } from './useLocalStorage/useLocalStorage';
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { useIntersectionObserver } from './useIntersectionObserver';
|
||||
|
||||
describe('useIntersectionObserver', () => {
|
||||
let observerCallback: IntersectionObserverCallback;
|
||||
let observeMock = vi.fn();
|
||||
let unobserveMock = vi.fn();
|
||||
let disconnectMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
observeMock = vi.fn();
|
||||
unobserveMock = vi.fn();
|
||||
disconnectMock = vi.fn();
|
||||
|
||||
class MockIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
observerCallback = callback;
|
||||
this.root = options?.root ?? null;
|
||||
this.rootMargin = options?.rootMargin ?? '';
|
||||
this.thresholds = options?.threshold
|
||||
? Array.isArray(options.threshold)
|
||||
? options.threshold
|
||||
: [options.threshold]
|
||||
: [0];
|
||||
}
|
||||
root: Element | Document | null;
|
||||
rootMargin: string;
|
||||
thresholds: readonly number[];
|
||||
takeRecords = vi.fn();
|
||||
observe = observeMock;
|
||||
unobserve = unobserveMock;
|
||||
disconnect = disconnectMock;
|
||||
}
|
||||
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return initial state as not intersecting', () => {
|
||||
const ref = { current: document.createElement('div') };
|
||||
const { result } = renderHook(() => useIntersectionObserver(ref));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should call observe on mount', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
renderHook(() => useIntersectionObserver(ref));
|
||||
|
||||
expect(observeMock).toHaveBeenCalledWith(element);
|
||||
});
|
||||
|
||||
it('should update isIntersecting when observer triggers', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
const { result } = renderHook(() => useIntersectionObserver(ref));
|
||||
|
||||
const entries = [
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: element,
|
||||
},
|
||||
] as unknown as IntersectionObserverEntry[];
|
||||
|
||||
act(() => {
|
||||
observerCallback(entries, {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should disconnect after first intersection if once is true', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
renderHook(() => useIntersectionObserver(ref, { once: true }));
|
||||
|
||||
const entries = [
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: element,
|
||||
},
|
||||
] as unknown as IntersectionObserverEntry[];
|
||||
|
||||
act(() => {
|
||||
observerCallback(entries, {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
expect(disconnectMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not disconnect after first intersection if once is false', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
renderHook(() => useIntersectionObserver(ref, { once: false }));
|
||||
|
||||
const entries = [
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: element,
|
||||
},
|
||||
] as unknown as IntersectionObserverEntry[];
|
||||
|
||||
act(() => {
|
||||
observerCallback(entries, {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
expect(disconnectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call disconnect on unmount', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
const { unmount } = renderHook(() => useIntersectionObserver(ref));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(disconnectMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not observe if ref is null', () => {
|
||||
const ref = { current: null };
|
||||
renderHook(() => useIntersectionObserver(ref));
|
||||
|
||||
expect(observeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return true immediately if IntersectionObserver is not available', () => {
|
||||
vi.stubGlobal('IntersectionObserver', undefined);
|
||||
const ref = { current: document.createElement('div') };
|
||||
const { result } = renderHook(() => useIntersectionObserver(ref));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Options for useIntersectionObserver */
|
||||
export interface UseIntersectionObserverOptions extends IntersectionObserverInit {
|
||||
/** Whether to disconnect after the first intersection */
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that observes if an element is intersecting with its root.
|
||||
*
|
||||
* @param ref - A React ref to the element to observe.
|
||||
* @param options - IntersectionObserver configuration.
|
||||
* @returns Boolean indicating if the element is currently intersecting.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const MyComponent = () => {
|
||||
* const ref = useRef<HTMLDivElement>(null);
|
||||
* const isVisible = useIntersectionObserver(ref);
|
||||
*
|
||||
* return <div ref={ref}>{isVisible ? 'I am visible!' : 'Scroll down...'}</div>;
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export const useIntersectionObserver = <T extends HTMLElement>(
|
||||
ref: React.RefObject<T | null>,
|
||||
{ once = true, ...options }: UseIntersectionObserverOptions = {}
|
||||
): boolean => {
|
||||
const [isIntersecting, setIntersecting] = useState(false);
|
||||
const { root, rootMargin, threshold } = options;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setIntersecting(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observeTarget = ref.current;
|
||||
if (!observeTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIntersecting(entry.isIntersecting);
|
||||
if (entry.isIntersecting && once) {
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ root, rootMargin, threshold }
|
||||
);
|
||||
|
||||
observer.observe(observeTarget);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [ref, once, root, rootMargin, threshold]);
|
||||
|
||||
return isIntersecting;
|
||||
};
|
||||
@@ -39,9 +39,8 @@ export function useLocalStorage<T>(
|
||||
const getSnapshot = useCallback(() => manager.get(), [manager]);
|
||||
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot, () => {
|
||||
// During SSR, we can't access localStorage.
|
||||
// However, createLocalStorage is designed to throw if accessed during SSR.
|
||||
// This hook is intended to be used in client-side components.
|
||||
// SSR snapshot: createLocalStorage returns a no-op manager in Node context,
|
||||
// so manager.get() safely returns defaultValue without accessing localStorage.
|
||||
return manager.get();
|
||||
});
|
||||
|
||||
|
||||
@@ -44,9 +44,15 @@ describe('lib/window/localStorage', () => {
|
||||
for (const key in mockStorage) delete mockStorage[key];
|
||||
});
|
||||
|
||||
it('should throw if window is undefined', () => {
|
||||
it('returns a no-op manager when window is undefined (SSR/SSG context)', () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
expect(() => createLocalStorage(KEY, DEFAULT_VALUE)).toThrow();
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
expect(manager.key).toBe(KEY);
|
||||
expect(manager.get()).toEqual(DEFAULT_VALUE);
|
||||
expect(() => manager.set({ foo: 'x' })).not.toThrow();
|
||||
expect(() => manager.remove()).not.toThrow();
|
||||
// get() still returns defaultValue after set (no-op in SSR)
|
||||
expect(manager.get()).toEqual(DEFAULT_VALUE);
|
||||
});
|
||||
|
||||
it('should get default value if empty', () => {
|
||||
|
||||
@@ -17,7 +17,8 @@ export interface StorageManager<T> {
|
||||
/**
|
||||
* Creates a type-safe manager for a localStorage key.
|
||||
*
|
||||
* This utility is browser-only and will throw if accessed during SSR.
|
||||
* In SSR/SSG context (no window), returns a no-op manager that always
|
||||
* returns the default value. In the browser, uses localStorage as usual.
|
||||
* Ensure you use a strict namespace for the key to avoid monorepo collisions.
|
||||
* Recommended format: `ermnvldmr/{package}/{path}/{module}`
|
||||
* Example: `ermnvldmr/ui/lib/theme`
|
||||
@@ -31,15 +32,19 @@ export interface StorageManager<T> {
|
||||
* const theme = createLocalStorage('ermnvldmr/ui/lib/theme', 'system');
|
||||
* console.log(theme.get());
|
||||
* ```
|
||||
*
|
||||
* @throws Error if window or localStorage is undefined.
|
||||
*/
|
||||
export function createLocalStorage<T>(key: string, defaultValue: T): StorageManager<T> {
|
||||
if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
|
||||
throw new Error(
|
||||
`[createLocalStorage] Attempted to access storage for key "${key}" outside of a browser environment. ` +
|
||||
'This utility is strictly for client-side use.'
|
||||
);
|
||||
return {
|
||||
key,
|
||||
get: () => defaultValue,
|
||||
set: () => {
|
||||
/* no-op in SSR/SSG context */
|
||||
},
|
||||
remove: () => {
|
||||
/* no-op in SSR/SSG context */
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const manager: StorageManager<T> = {
|
||||
|
||||
@@ -30,6 +30,6 @@
|
||||
"babel-plugin-istanbul": "^7.0.1",
|
||||
"storybook-react-rsbuild": "^1.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.2.2"
|
||||
"vite": "^8.0.5"
|
||||
}
|
||||
}
|
||||
@@ -22,3 +22,27 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// jsdom sometimes does not expose localStorage.clear(); provide a functional polyfill
|
||||
if (typeof window.localStorage.clear !== 'function') {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string): string | null => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string): void => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string): void => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: (): void => {
|
||||
store.clear();
|
||||
},
|
||||
key: (index: number): string | null => [...store.keys()][index] ?? null,
|
||||
get length(): number {
|
||||
return store.size;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
"clean": "rm -rf dist coverage storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ermnvldmr/i18n": "workspace:*",
|
||||
"@ermnvldmr/stl": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
@@ -82,7 +83,7 @@
|
||||
"jsdom": "^28.0.0",
|
||||
"playwright": "^1.57.0",
|
||||
"postcss": "^8.5.6",
|
||||
"storybook": "^8.6.15",
|
||||
"storybook": "^8.6.17",
|
||||
"storybook-react-rsbuild": "^1.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Accordion } from './Accordion';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
const meta: Meta<typeof Accordion> = {
|
||||
title: 'Components/Accordion',
|
||||
component: Accordion,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['single', 'multiple'],
|
||||
},
|
||||
collapsible: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Accordion>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
type: 'single',
|
||||
collapsible: true,
|
||||
},
|
||||
render: (args) => (
|
||||
<Accordion {...args}>
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger level={2}>Is it accessible?</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
Yes. It adheres to the WAI-ARIA design pattern.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Trigger level={2}>Is it styled?</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
Yes. It comes with default styles that matches the design system.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-3">
|
||||
<Accordion.Trigger level={2}>Is it animated?</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
Yes. It uses CSS Grid transitions for smooth height animation.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
),
|
||||
};
|
||||
|
||||
export const Multiple: Story = {
|
||||
args: {
|
||||
type: 'multiple',
|
||||
},
|
||||
render: (args) => (
|
||||
<Accordion {...args}>
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger level={3}>Section One</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
This section can be open alongside others.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Trigger level={3}>Section Two</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
You can open as many sections as you want in multiple mode.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
),
|
||||
};
|
||||
|
||||
export const DifferentHeaderLevels: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single">
|
||||
<Accordion.Item value="h1">
|
||||
<Accordion.Trigger level={1}>Header Level 1</Accordion.Trigger>
|
||||
<Accordion.Content>Content for H1</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="h2">
|
||||
<Accordion.Trigger level={2}>Header Level 2</Accordion.Trigger>
|
||||
<Accordion.Content>Content for H2</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="h3">
|
||||
<Accordion.Trigger level={3}>Header Level 3</Accordion.Trigger>
|
||||
<Accordion.Content>Content for H3</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="h4">
|
||||
<Accordion.Trigger level={4}>Header Level 4</Accordion.Trigger>
|
||||
<Accordion.Content>Content for H4</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
),
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger level={3}>Enabled Item</Accordion.Trigger>
|
||||
<Accordion.Content>This one works fine.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item disabled value="item-2">
|
||||
<Accordion.Trigger level={3}>Disabled Item</Accordion.Trigger>
|
||||
<Accordion.Content>You should not see this.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { Accordion } from './Accordion';
|
||||
|
||||
describe('Accordion', () => {
|
||||
it('renders correctly with all sub-components', () => {
|
||||
render(
|
||||
<Accordion type="single">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger level={2}>Trigger 1</Accordion.Trigger>
|
||||
<Accordion.Content>Content 1</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Trigger 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Content 1')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('toggles content on click in single mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Accordion type="single">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
|
||||
<Accordion.Content>Content 1</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
|
||||
<Accordion.Content>Content 2</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
const trigger1 = screen.getByText('Trigger 1');
|
||||
const trigger2 = screen.getByText('Trigger 2');
|
||||
|
||||
await user.click(trigger1);
|
||||
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await user.click(trigger2);
|
||||
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('allows multiple items to be open in multiple mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Accordion type="multiple">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
|
||||
<Accordion.Content>Content 1</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
|
||||
<Accordion.Content>Content 2</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Trigger 1'));
|
||||
await user.click(screen.getByText('Trigger 2'));
|
||||
|
||||
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('respects the collapsible prop in single mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Accordion collapsible={false} type="single">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
|
||||
<Accordion.Content>Content 1</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
const trigger = screen.getByText('Trigger 1');
|
||||
|
||||
// Open it
|
||||
await user.click(trigger);
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Try to close it (should stay open because collapsible={false})
|
||||
await user.click(trigger);
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('does not toggle when disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Accordion type="single">
|
||||
<Accordion.Item disabled value="item-1">
|
||||
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
|
||||
<Accordion.Content>Content 1</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
const trigger = screen.getByText('Trigger 1');
|
||||
await user.click(trigger);
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('works as a controlled component', async () => {
|
||||
const user = userEvent.setup();
|
||||
const TestComponent = () => {
|
||||
const [value, setValue] = React.useState<string[]>(['item-1']);
|
||||
return (
|
||||
<Accordion type="single" value={value} onValueChange={setValue}>
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
|
||||
<Accordion.Content>Content 1</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
|
||||
<Accordion.Content>Content 2</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
await user.click(screen.getByText('Trigger 2'));
|
||||
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('works as an uncontrolled component with defaultValue', () => {
|
||||
render(
|
||||
<Accordion defaultValue={['item-2']} type="single">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
|
||||
<Accordion.Content>Content 1</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
|
||||
<Accordion.Content>Content 2</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('renders with valid HTML structure (heading containing button)', () => {
|
||||
render(
|
||||
<Accordion type="single">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger level={3}>Trigger 1</Accordion.Trigger>
|
||||
<Accordion.Content>Content 1</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 3 });
|
||||
const button = screen.getByRole('button');
|
||||
expect(heading).toContainElement(button);
|
||||
});
|
||||
|
||||
it('applies accessibility attributes to content when closed', () => {
|
||||
render(
|
||||
<Accordion type="single">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
|
||||
<Accordion.Content>Content 1</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
const content = screen.getByText('Content 1').closest('[role="region"]');
|
||||
expect(content).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(content).toHaveAttribute('inert');
|
||||
});
|
||||
|
||||
it('removes accessibility hiding attributes when open', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Accordion type="single">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
|
||||
<Accordion.Content>Content 1</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Trigger 1'));
|
||||
const content = screen.getByText('Content 1').closest('[role="region"]');
|
||||
expect(content).toHaveAttribute('aria-hidden', 'false');
|
||||
expect(content).not.toHaveAttribute('inert');
|
||||
});
|
||||
|
||||
it('throws error when sub-components are used outside of Accordion', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => render(<Accordion.Trigger>Trigger</Accordion.Trigger>)).toThrow(
|
||||
'Accordion sub-components must be used within an Accordion component.'
|
||||
);
|
||||
|
||||
expect(() => render(<Accordion.Item value="1">Item</Accordion.Item>)).toThrow(
|
||||
'Accordion sub-components must be used within an Accordion component.'
|
||||
);
|
||||
|
||||
expect(() => render(<Accordion.Content>Content</Accordion.Content>)).toThrow(
|
||||
'AccordionItem sub-components must be used within an AccordionItem component.'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import { AccordionContent } from './AccordionContent/AccordionContent';
|
||||
import { AccordionItem } from './AccordionItem/AccordionItem';
|
||||
import { AccordionTrigger } from './AccordionTrigger/AccordionTrigger';
|
||||
import { AccordionContext } from './contexts/AccordionContext/AccordionContext';
|
||||
|
||||
import type { AccordionProps } from './types';
|
||||
|
||||
/**
|
||||
* An Accordion component that allows users to reveal and hide content sections.
|
||||
*
|
||||
* Built with a compound component pattern for maximum flexibility.
|
||||
* @example Basic usage:
|
||||
*/
|
||||
const AccordionRoot = ({
|
||||
type = 'single',
|
||||
value: controlledValue,
|
||||
defaultValue = [],
|
||||
onValueChange,
|
||||
collapsible = true,
|
||||
children,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
}: AccordionProps) => {
|
||||
const [internalValue, setInternalValue] = useState<string[]>(defaultValue);
|
||||
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const currentValues = isControlled ? controlledValue : internalValue;
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(itemValue: string) => {
|
||||
let nextValues: string[];
|
||||
|
||||
if (type === 'single') {
|
||||
const isCurrentOpen = currentValues.includes(itemValue);
|
||||
if (isCurrentOpen && collapsible) {
|
||||
nextValues = [];
|
||||
} else if (isCurrentOpen && !collapsible) {
|
||||
nextValues = [itemValue];
|
||||
} else {
|
||||
nextValues = [itemValue];
|
||||
}
|
||||
} else {
|
||||
nextValues = currentValues.includes(itemValue)
|
||||
? currentValues.filter((v) => v !== itemValue)
|
||||
: [...currentValues, itemValue];
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(nextValues);
|
||||
}
|
||||
onValueChange?.(nextValues);
|
||||
},
|
||||
[type, currentValues, collapsible, isControlled, onValueChange]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
type,
|
||||
value: currentValues,
|
||||
onValueChange: handleValueChange,
|
||||
collapsible,
|
||||
}),
|
||||
[type, currentValues, handleValueChange, collapsible]
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionContext.Provider value={contextValue}>
|
||||
<div className={cn('w-full', className)} data-testid={testId}>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A collapsible accordion component system.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Accordion type="single" collapsible>
|
||||
* <Accordion.Item value="item-1">
|
||||
* <Accordion.Trigger level={2}>Is it accessible?</Accordion.Trigger>
|
||||
* <Accordion.Content>Yes. It adheres to the WAI-ARIA design pattern.</Accordion.Content>
|
||||
* </Accordion.Item>
|
||||
* </Accordion>
|
||||
* ```
|
||||
*/
|
||||
export const Accordion = Object.assign(AccordionRoot, {
|
||||
Item: AccordionItem,
|
||||
Trigger: AccordionTrigger,
|
||||
Content: AccordionContent,
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React from 'react';
|
||||
|
||||
import { useAccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
|
||||
|
||||
import type { AccordionContentProps } from '../types';
|
||||
|
||||
/**
|
||||
* The collapsible content of an Accordion item.
|
||||
*
|
||||
* Uses a CSS Grid transition for smooth height animation.
|
||||
* @example Basic usage:
|
||||
*/
|
||||
export const AccordionContent = ({ children, className }: AccordionContentProps) => {
|
||||
const { value, isOpen } = useAccordionItemContext();
|
||||
|
||||
return (
|
||||
<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
|
||||
)}
|
||||
id={`accordion-content-${value}`}
|
||||
inert={!isOpen ? true : undefined}
|
||||
role="region"
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="pb-4 pt-0 text-sm leading-relaxed">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useAccordionContext } from '../contexts/AccordionContext/AccordionContext';
|
||||
import { AccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
|
||||
|
||||
import type { AccordionItemProps } from '../types';
|
||||
|
||||
/**
|
||||
* An individual item within an Accordion.
|
||||
*
|
||||
* Provides context for the Trigger and Content sub-components.
|
||||
* @example Basic usage:
|
||||
*/
|
||||
export const AccordionItem = ({
|
||||
value,
|
||||
children,
|
||||
disabled = false,
|
||||
className,
|
||||
}: AccordionItemProps) => {
|
||||
const { value: openValues } = useAccordionContext();
|
||||
const isOpen = openValues.includes(value);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
value,
|
||||
isOpen,
|
||||
disabled,
|
||||
}),
|
||||
[value, isOpen, disabled]
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItemContext.Provider value={contextValue}>
|
||||
<div className={cn('border-b border-[var(--rb-outline)]/40', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionItemContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useButton } from 'react-aria';
|
||||
|
||||
import { Header } from '../../Header/Header';
|
||||
import { HStack } from '../../HStack/HStack';
|
||||
import { useAccordionContext } from '../contexts/AccordionContext/AccordionContext';
|
||||
import { useAccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
|
||||
|
||||
import type { AccordionTriggerProps } from '../types';
|
||||
|
||||
/**
|
||||
* The interactive trigger for an Accordion item.
|
||||
*
|
||||
* Wraps the Header component and adds an expansion arrow.
|
||||
* @example Basic usage:
|
||||
* ```tsx
|
||||
* <Accordion.Trigger>Click me</Accordion.Trigger>
|
||||
* ```
|
||||
*/
|
||||
export const AccordionTrigger = ({
|
||||
children,
|
||||
className,
|
||||
level = 3,
|
||||
...headerProps
|
||||
}: AccordionTriggerProps) => {
|
||||
const { onValueChange } = useAccordionContext();
|
||||
const { value, isOpen, disabled: isDisabled } = useAccordionItemContext();
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!isDisabled) {
|
||||
onValueChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const { buttonProps } = useButton(
|
||||
{
|
||||
onPress: handleToggle,
|
||||
isDisabled,
|
||||
'aria-controls': `accordion-content-${value}`,
|
||||
'aria-expanded': isOpen,
|
||||
id: `accordion-trigger-${value}`,
|
||||
type: 'button',
|
||||
},
|
||||
ref
|
||||
);
|
||||
|
||||
return (
|
||||
<Header level={level} {...headerProps}>
|
||||
<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
|
||||
)}
|
||||
>
|
||||
<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'
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1">{children}</span>
|
||||
</HStack>
|
||||
</button>
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { AccordionContextValue } from '../../types';
|
||||
|
||||
/**
|
||||
* Context for sharing accordion state with items.
|
||||
*/
|
||||
export const AccordionContext = createContext<AccordionContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Hook to access the Accordion context.
|
||||
* @example Using the context:
|
||||
*/
|
||||
export function useAccordionContext(): AccordionContextValue {
|
||||
const context = useContext(AccordionContext);
|
||||
if (!context) {
|
||||
throw new Error('Accordion sub-components must be used within an Accordion component.');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { AccordionItemContextValue } from '../../types';
|
||||
|
||||
/**
|
||||
* Context for sharing specific accordion item state.
|
||||
*/
|
||||
export const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Hook to access the AccordionItem context.
|
||||
* @example Using the context:
|
||||
*/
|
||||
export function useAccordionItemContext(): AccordionItemContextValue {
|
||||
const context = useContext(AccordionItemContext);
|
||||
if (!context) {
|
||||
throw new Error('AccordionItem sub-components must be used within an AccordionItem component.');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './Accordion';
|
||||
export * from './types';
|
||||
export * from './contexts/AccordionContext/AccordionContext';
|
||||
export * from './contexts/AccordionItemContext/AccordionItemContext';
|
||||
|
||||
// Export sub-components individually for flexibility
|
||||
export * from './AccordionItem/AccordionItem';
|
||||
export * from './AccordionTrigger/AccordionTrigger';
|
||||
export * from './AccordionContent/AccordionContent';
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { HeaderProps } from '../Header/Header';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
|
||||
/**
|
||||
* Type of accordion behavior.
|
||||
* - 'single': Only one item can be open at a time.
|
||||
* - 'multiple': Multiple items can be open at a time.
|
||||
*/
|
||||
export type AccordionType = 'single' | 'multiple';
|
||||
|
||||
/**
|
||||
* Context value for the root Accordion component.
|
||||
*/
|
||||
export interface AccordionContextValue {
|
||||
/** Type of accordion behavior */
|
||||
type: AccordionType;
|
||||
/** Currently open values */
|
||||
value: string[];
|
||||
/** Callback when open values change */
|
||||
onValueChange: (value: string) => void;
|
||||
/** Whether the accordion is collapsible in single mode */
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context value for an individual Accordion item.
|
||||
*/
|
||||
export interface AccordionItemContextValue {
|
||||
/** Unique value for this item */
|
||||
value: string;
|
||||
/** Whether this item is currently open */
|
||||
isOpen: boolean;
|
||||
/** Whether this item is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the root Accordion component.
|
||||
*/
|
||||
export interface AccordionProps extends ClassNameProps, TestIdProps {
|
||||
/** Content of the accordion */
|
||||
children: ReactNode;
|
||||
/** Type of accordion behavior */
|
||||
type?: AccordionType;
|
||||
/** Controlled value of open items */
|
||||
value?: string[];
|
||||
/** Default value of open items (uncontrolled) */
|
||||
defaultValue?: string[];
|
||||
/** Callback when open values change */
|
||||
onValueChange?: (value: string[]) => void;
|
||||
/** Whether the accordion is collapsible in single mode */
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the AccordionItem component.
|
||||
*/
|
||||
export interface AccordionItemProps extends ClassNameProps {
|
||||
/** Unique value for this item */
|
||||
value: string;
|
||||
/** Content of the item (Trigger and Content) */
|
||||
children: ReactNode;
|
||||
/** Whether the item is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the AccordionTrigger component.
|
||||
*/
|
||||
export interface AccordionTriggerProps extends ClassNameProps, Omit<HeaderProps, 'children' | 'level'> {
|
||||
/** Content of the trigger (usually text) */
|
||||
children: ReactNode;
|
||||
/**
|
||||
* The semantic header level (1-6).
|
||||
* @default 3
|
||||
*/
|
||||
level?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the AccordionContent component.
|
||||
*/
|
||||
export interface AccordionContentProps extends ClassNameProps {
|
||||
/** Content to be revealed */
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export const Article = memo(function Article({
|
||||
className,
|
||||
}: ArticleProps) {
|
||||
return (
|
||||
<Container className={className} href={href} padding={4} onPress={onPress}>
|
||||
<Container border className={className} href={href} padding={4} rounded="md" onPress={onPress}>
|
||||
<VStack align="stretch" gap={4}>
|
||||
<VStack align="stretch" gap={2}>
|
||||
<Header level={4}>{headline}</Header>
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { Star, Code, Layout, Send, Zap, Shield, Cpu, Gamepad2, Plane, BookOpen, Check, Gift } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { BentoGrid } from './BentoGrid';
|
||||
import { Header } from '../Header/Header';
|
||||
import { Text } from '../Text/Text';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
const meta: Meta<typeof BentoGrid> = {
|
||||
title: 'Components/BentoGrid',
|
||||
component: BentoGrid,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof BentoGrid>;
|
||||
|
||||
export const Showcase: Story = {
|
||||
render: () => (
|
||||
<div className="w-full max-w-6xl p-4">
|
||||
<BentoGrid>
|
||||
<BentoGrid.ImageCard
|
||||
alt="Developer workstation"
|
||||
colSpan={2}
|
||||
overlayDescription="A deep dive into modern web architecture and performance optimization."
|
||||
overlayTitle="Featured Project"
|
||||
rowSpan={2}
|
||||
src="https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&q=80&w=2072"
|
||||
/>
|
||||
<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} />}
|
||||
title="Lightning Fast"
|
||||
/>
|
||||
<BentoGrid.CTACard
|
||||
buttonText="Get Started"
|
||||
description="Connect with other developers and share your projects."
|
||||
href="#"
|
||||
title="Join the Community"
|
||||
variant="primary"
|
||||
/>
|
||||
<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} />}
|
||||
title="Type Safe"
|
||||
/>
|
||||
<BentoGrid.ImageCard
|
||||
alt="Code on screen"
|
||||
colSpan={2}
|
||||
overlayTitle="Clean Code"
|
||||
src="https://images.unsplash.com/photo-1461749280684-dccba630e2f6?auto=format&fit=crop&q=80&w=2069"
|
||||
/>
|
||||
<BentoGrid.InfoCard
|
||||
description="Easily extend and customize the design system with our modular component library."
|
||||
icon={<Cpu className="text-rb-blue-500" size={24} />}
|
||||
title="Modular Architecture"
|
||||
/>
|
||||
<BentoGrid.CTACard
|
||||
buttonText="View Docs"
|
||||
description="Read the full documentation to learn more about the design system."
|
||||
href="#"
|
||||
title="Documentation"
|
||||
/>
|
||||
<BentoGrid.BaseCard className="bg-rb-tertiary-base/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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-6 py-2 bg-rb-text text-rb-base rounded-md font-medium">Subscribe</button>
|
||||
</div>
|
||||
</BentoGrid.BaseCard>
|
||||
</BentoGrid>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const LayoutPatterns: Story = {
|
||||
render: () => (
|
||||
<div className="w-full max-w-6xl p-4 flex flex-col gap-12">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const AllCardTypes: Story = {
|
||||
render: () => (
|
||||
<div className="w-full max-w-6xl p-4">
|
||||
<BentoGrid>
|
||||
<BentoGrid.InfoCard
|
||||
description="Used for features or information. Can have an icon and a footer."
|
||||
footer={<div className="text-xs font-mono">v1.0.0</div>}
|
||||
icon={<Layout size={24} />}
|
||||
title="Info Card"
|
||||
/>
|
||||
<BentoGrid.ImageCard
|
||||
alt="Abstract image"
|
||||
overlayDescription="Perfect for visual showcases with text overlays."
|
||||
overlayTitle="Image Card"
|
||||
src="https://images.unsplash.com/photo-1481487196290-c152efe083f5?auto=format&fit=crop&q=80&w=2048"
|
||||
/>
|
||||
<BentoGrid.CTACard
|
||||
buttonText="Learn More"
|
||||
description="Drives action with an outline button."
|
||||
href="#"
|
||||
title="CTA Card (Default)"
|
||||
/>
|
||||
<BentoGrid.CTACard
|
||||
buttonText="Sign Up Now"
|
||||
description="High prominence with a solid background."
|
||||
href="#"
|
||||
title="CTA Card (Primary)"
|
||||
variant="primary"
|
||||
/>
|
||||
<BentoGrid.ListCard
|
||||
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>}
|
||||
sections={[
|
||||
{
|
||||
value: 'features',
|
||||
label: 'Features',
|
||||
labelIcon: <Check size={18} />,
|
||||
labelClassName: 'text-rb-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">
|
||||
<Code size={20} />
|
||||
</div>
|
||||
<h4 className="font-bold">Base Card (Custom)</h4>
|
||||
<p className="text-sm">You can put any children here to create custom card types while maintaining the bento border and padding.</p>
|
||||
</BentoGrid.BaseCard>
|
||||
</BentoGrid>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WishlistVariant: Story = {
|
||||
render: () => (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<BentoGrid>
|
||||
<BentoGrid.ListCard
|
||||
colSpan={2}
|
||||
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">
|
||||
<Gamepad2 size={24} />
|
||||
</div>
|
||||
}
|
||||
rowSpan={2}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: 'Wanted',
|
||||
labelIcon: <Gift size={18} />,
|
||||
labelClassName: 'text-rb-orange-600',
|
||||
items: [
|
||||
'Steam Deck OLED 1TB',
|
||||
'Sony A7C II with 35mm lens',
|
||||
'Keychron Mechanical Keyboard'
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'owned',
|
||||
label: 'Already Owned',
|
||||
labelIcon: <Check size={18} />,
|
||||
labelClassName: 'text-rb-green-600',
|
||||
items: [
|
||||
'MacBook Pro M3 Max',
|
||||
'Sony WH-1000XM5 Headphones',
|
||||
'LG 27" 4K Monitor'
|
||||
]
|
||||
}
|
||||
]}
|
||||
title="Gadgets & Tech"
|
||||
/>
|
||||
|
||||
<BentoGrid.ListCard
|
||||
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">
|
||||
<Plane size={24} />
|
||||
</div>
|
||||
}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: 'Bucket List',
|
||||
labelIcon: <Gift size={18} />,
|
||||
labelClassName: 'text-rb-orange-600',
|
||||
items: [
|
||||
'Kyoto, Japan (Cherry Blossom Season)',
|
||||
'Reykjavik, Iceland',
|
||||
'Swiss Alps'
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'owned',
|
||||
label: 'Visited',
|
||||
labelIcon: <Check size={18} />,
|
||||
labelClassName: 'text-rb-green-600',
|
||||
items: [
|
||||
'Paris, France',
|
||||
'Rome, Italy',
|
||||
'London, UK'
|
||||
]
|
||||
}
|
||||
]}
|
||||
title="Travel Destinations"
|
||||
type="single"
|
||||
/>
|
||||
|
||||
<BentoGrid.ListCard
|
||||
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">
|
||||
<BookOpen size={24} />
|
||||
</div>
|
||||
}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: 'Reading List',
|
||||
labelIcon: <Gift size={18} />,
|
||||
labelClassName: 'text-rb-orange-600',
|
||||
items: [
|
||||
'Designing Data-Intensive Applications',
|
||||
'The Pragmatic Programmer',
|
||||
'Dune Messiah'
|
||||
]
|
||||
}
|
||||
]}
|
||||
title="Books to Read"
|
||||
type="single"
|
||||
/>
|
||||
</BentoGrid>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { BentoGrid } from './BentoGrid';
|
||||
|
||||
describe('BentoGrid', () => {
|
||||
it('renders all card types correctly', () => {
|
||||
render(
|
||||
<BentoGrid>
|
||||
<BentoGrid.BaseCard data-testid="base-card">Base Card Content</BentoGrid.BaseCard>
|
||||
<BentoGrid.ImageCard
|
||||
alt="Test Image"
|
||||
overlayDescription="Image Description"
|
||||
overlayTitle="Image Title"
|
||||
src="test-image.jpg"
|
||||
/>
|
||||
<BentoGrid.InfoCard
|
||||
description="Info Description"
|
||||
footer={<span>Footer Content</span>}
|
||||
title="Info Title"
|
||||
/>
|
||||
<BentoGrid.CTACard
|
||||
buttonText="Click Me"
|
||||
description="CTA Description"
|
||||
href="/test"
|
||||
title="CTA Title"
|
||||
/>
|
||||
<BentoGrid.ListCard
|
||||
description="List Description"
|
||||
sections={[
|
||||
{
|
||||
value: 's1',
|
||||
label: 'Section 1',
|
||||
items: ['Item A', 'Item B']
|
||||
}
|
||||
]}
|
||||
title="List Title"
|
||||
/>
|
||||
</BentoGrid>
|
||||
);
|
||||
|
||||
// BaseCard
|
||||
expect(screen.getByTestId('base-card')).toBeInTheDocument();
|
||||
expect(screen.getByText('Base Card Content')).toBeInTheDocument();
|
||||
|
||||
// ImageCard
|
||||
expect(screen.getByAltText('Test Image')).toBeInTheDocument();
|
||||
expect(screen.getByText('Image Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Image Description')).toBeInTheDocument();
|
||||
|
||||
// InfoCard
|
||||
expect(screen.getByText('Info Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Info Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('Footer Content')).toBeInTheDocument();
|
||||
|
||||
// CTACard
|
||||
expect(screen.getByText('CTA Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('CTA Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('Click Me')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Click Me/i })).toHaveAttribute('href', '/test');
|
||||
|
||||
// ListCard
|
||||
expect(screen.getByText('List Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('List Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('Section 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ListCard with object items (stable keys)', () => {
|
||||
render(
|
||||
<BentoGrid.ListCard
|
||||
sections={[
|
||||
{
|
||||
value: 's1',
|
||||
label: 'Section 1',
|
||||
items: [
|
||||
{ key: 'item-1', node: 'Stable Item 1' },
|
||||
'Raw Item 2'
|
||||
]
|
||||
}
|
||||
]}
|
||||
title="List Title"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Stable Item 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Raw Item 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies span classes correctly', () => {
|
||||
render(
|
||||
<BentoGrid>
|
||||
<BentoGrid.BaseCard colSpan={2} rowSpan={2}>
|
||||
Spanning Card
|
||||
</BentoGrid.BaseCard>
|
||||
</BentoGrid>
|
||||
);
|
||||
|
||||
const card = screen.getByText('Spanning Card');
|
||||
expect(card).toHaveClass('md:col-span-2');
|
||||
expect(card).toHaveClass('md:row-span-2');
|
||||
});
|
||||
|
||||
it('applies primary variant to CTACard', () => {
|
||||
render(
|
||||
<BentoGrid>
|
||||
<BentoGrid.CTACard
|
||||
buttonText="Button"
|
||||
description="Description"
|
||||
href="/"
|
||||
title="Primary CTA"
|
||||
variant="primary"
|
||||
/>
|
||||
</BentoGrid>
|
||||
);
|
||||
|
||||
const title = screen.getByText('Primary CTA');
|
||||
const card = title.closest('.bg-rb-primary-base');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { cn, genericMemo } from '@ermnvldmr/stl';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { BentoGridBaseCard } from './BentoGridBaseCard';
|
||||
import { BentoGridCTACard } from './BentoGridCTACard';
|
||||
import { BentoGridImageCard } from './BentoGridImageCard';
|
||||
import { BentoGridInfoCard } from './BentoGridInfoCard';
|
||||
import { BentoGridListCard } from './BentoGridListCard';
|
||||
|
||||
import type { BentoGridProps } from './types';
|
||||
|
||||
const BentoGridComponent = forwardRef<HTMLDivElement, BentoGridProps>(function BentoGrid(
|
||||
{ children, className, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'grid w-full grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A responsive Bento Grid system for creating structured, masonry-like layouts.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <BentoGrid>
|
||||
* <BentoGrid.ImageCard
|
||||
* colSpan={2}
|
||||
* rowSpan={2}
|
||||
* src="/image.jpg"
|
||||
* alt="Showcase"
|
||||
* overlayTitle="Featured"
|
||||
* />
|
||||
* <BentoGrid.InfoCard
|
||||
* title="Details"
|
||||
* description="Key features of the project."
|
||||
* />
|
||||
* </BentoGrid>
|
||||
* ```
|
||||
*/
|
||||
export const BentoGrid = Object.assign(genericMemo(BentoGridComponent), {
|
||||
BaseCard: BentoGridBaseCard,
|
||||
ImageCard: BentoGridImageCard,
|
||||
InfoCard: BentoGridInfoCard,
|
||||
CTACard: BentoGridCTACard,
|
||||
ListCard: BentoGridListCard,
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
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]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Base card component that provides the foundational styling for all Bento cards.
|
||||
* @example Basic usage:
|
||||
*/
|
||||
export const BentoGridBaseCard = ({
|
||||
children,
|
||||
className,
|
||||
colSpan,
|
||||
rowSpan,
|
||||
...props
|
||||
}: BentoCardBaseProps) => {
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React from 'react';
|
||||
|
||||
import { BentoGridBaseCard } from './BentoGridBaseCard';
|
||||
import { Button } from '../Button/Button';
|
||||
import { Header } from '../Header/Header';
|
||||
import { Text } from '../Text/Text';
|
||||
import { VStack } from '../VStack/VStack';
|
||||
|
||||
import type { BentoCardBaseProps } from './types';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface BentoGridCTACardProps extends BentoCardBaseProps {
|
||||
/** The title of the call to action */
|
||||
title: string;
|
||||
/** The description text */
|
||||
description: string;
|
||||
/** The text to display on the action button */
|
||||
buttonText: string;
|
||||
/** The destination URL for the action */
|
||||
href: string;
|
||||
/**
|
||||
* Visual variant for the card.
|
||||
* 'default' uses standard card styling.
|
||||
* 'primary' uses a more prominent background.
|
||||
*/
|
||||
variant?: 'default' | 'primary';
|
||||
}
|
||||
|
||||
/**
|
||||
* A Bento card designed specifically to drive user action.
|
||||
* @example Basic usage:
|
||||
*/
|
||||
export const BentoGridCTACard = ({
|
||||
title,
|
||||
description,
|
||||
buttonText,
|
||||
href,
|
||||
variant = 'default',
|
||||
className,
|
||||
...baseProps
|
||||
}: BentoGridCTACardProps) => {
|
||||
return (
|
||||
<BentoGridBaseCard
|
||||
{...baseProps}
|
||||
className={cn(
|
||||
variant === 'primary' && 'bg-rb-primary-base border-rb-primary-base',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<VStack className="h-full justify-between" gap={4}>
|
||||
<VStack gap={2}>
|
||||
<Header className={cn(variant === 'primary' && 'text-rb-primary-text')} level={3}>
|
||||
{title}
|
||||
</Header>
|
||||
<Text
|
||||
className={cn(variant === 'primary' ? 'text-rb-primary-text/80' : 'text-rb-muted-text')}
|
||||
size="m"
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
</VStack>
|
||||
<div className="mt-auto">
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
href={href}
|
||||
variant={variant === 'primary' ? 'solid' : 'outline'}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</VStack>
|
||||
</BentoGridBaseCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React from 'react';
|
||||
|
||||
import { BentoGridBaseCard } from './BentoGridBaseCard';
|
||||
import { Header } from '../Header/Header';
|
||||
import { Image } from '../Image/Image';
|
||||
import { Text } from '../Text/Text';
|
||||
import { VStack } from '../VStack/VStack';
|
||||
|
||||
import type { BentoCardBaseProps } from './types';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface BentoGridImageCardProps extends BentoCardBaseProps {
|
||||
/** Source URL of the image */
|
||||
src: string;
|
||||
/** Alt text for the image */
|
||||
alt: string;
|
||||
/** Optional title to overlay on the image */
|
||||
overlayTitle?: string;
|
||||
/** Optional description to overlay on the image */
|
||||
overlayDescription?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Bento card designed to showcase an image with optional textual overlays.
|
||||
* @example Basic usage:
|
||||
*/
|
||||
export const BentoGridImageCard = ({
|
||||
src,
|
||||
alt,
|
||||
overlayTitle,
|
||||
overlayDescription,
|
||||
className,
|
||||
...baseProps
|
||||
}: BentoGridImageCardProps) => {
|
||||
const hasOverlay = !!overlayTitle || !!overlayDescription;
|
||||
|
||||
return (
|
||||
<BentoGridBaseCard {...baseProps} className={cn('p-0', className)}>
|
||||
<div className="relative h-full w-full">
|
||||
<Image
|
||||
alt={alt}
|
||||
className="h-full w-full object-cover"
|
||||
height="100%"
|
||||
ratio="auto"
|
||||
src={src}
|
||||
/>
|
||||
|
||||
{hasOverlay && (
|
||||
<div className="absolute inset-0 flex flex-col justify-end bg-linear-to-t from-black/80 via-black/40 to-transparent p-6 text-white">
|
||||
<VStack gap={1}>
|
||||
{overlayTitle && (
|
||||
<Header className="text-white" level={3}>
|
||||
{overlayTitle}
|
||||
</Header>
|
||||
)}
|
||||
{overlayDescription && (
|
||||
<Text className="text-white/90" size="s">
|
||||
{overlayDescription}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BentoGridBaseCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
|
||||
import { BentoGridBaseCard } from './BentoGridBaseCard';
|
||||
import { Header } from '../Header/Header';
|
||||
import { Text } from '../Text/Text';
|
||||
import { VStack } from '../VStack/VStack';
|
||||
|
||||
import type { BentoCardBaseProps } from './types';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface BentoGridInfoCardProps extends BentoCardBaseProps {
|
||||
/** The title of the card */
|
||||
title: string;
|
||||
/** The description text */
|
||||
description?: string;
|
||||
/** Optional icon or graphic to display at the top */
|
||||
icon?: React.ReactNode;
|
||||
/** Optional footer content */
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A versatile Bento card for text-heavy content or feature highlights.
|
||||
* @example Basic usage:
|
||||
*/
|
||||
export const BentoGridInfoCard = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
footer,
|
||||
...baseProps
|
||||
}: BentoGridInfoCardProps) => {
|
||||
return (
|
||||
<BentoGridBaseCard {...baseProps}>
|
||||
<VStack className="h-full justify-between" gap={4}>
|
||||
<VStack gap={2}>
|
||||
{icon && <div className="text-rb-primary-text">{icon}</div>}
|
||||
<Header level={3}>{title}</Header>
|
||||
{description && <Text className="text-rb-muted-text" size="m">{description}</Text>}
|
||||
</VStack>
|
||||
{footer && <div className="mt-auto">{footer}</div>}
|
||||
</VStack>
|
||||
</BentoGridBaseCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React from 'react';
|
||||
|
||||
import { Accordion } from '../Accordion';
|
||||
import { Header } from '../Header/Header';
|
||||
import { HStack } from '../HStack/HStack';
|
||||
import { List } from '../List';
|
||||
import { BentoGridBaseCard } from './BentoGridBaseCard';
|
||||
import { Text } from '../Text/Text';
|
||||
import { VStack } from '../VStack/VStack';
|
||||
|
||||
import type { BentoCardBaseProps } from './types';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface BentoGridListCardSection {
|
||||
/** Unique identifier for the accordion section */
|
||||
value: string;
|
||||
/** Label text for the accordion trigger */
|
||||
label: string;
|
||||
/** Optional icon next to the label */
|
||||
labelIcon?: React.ReactNode;
|
||||
/** Optional class name for the label text color (e.g. 'text-rb-orange-600') */
|
||||
labelClassName?: string;
|
||||
/** List of items to display when expanded. Can be raw nodes or objects with keys. */
|
||||
items: (React.ReactNode | { key: string; node: React.ReactNode })[];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface BentoGridListCardProps extends BentoCardBaseProps {
|
||||
/** Title of the card */
|
||||
title: string;
|
||||
/** Description text */
|
||||
description?: string;
|
||||
/** Optional icon next to the title. For colored backgrounds, wrap the icon in a styled div. */
|
||||
icon?: React.ReactNode;
|
||||
/** Sections containing lists of items, rendered as Accordions */
|
||||
sections: BentoGridListCardSection[];
|
||||
/** Which sections are expanded by default (uses section value) */
|
||||
defaultValue?: string[];
|
||||
/** Whether multiple sections can be open at once */
|
||||
type?: 'single' | 'multiple';
|
||||
}
|
||||
|
||||
/**
|
||||
* A Bento card designed for categorized lists of items using Accordions.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <BentoGrid.ListCard
|
||||
* title="My List"
|
||||
* sections={[
|
||||
* { value: 's1', label: 'Section 1', items: ['Item 1', 'Item 2'] }
|
||||
* ]}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const BentoGridListCard = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
sections,
|
||||
defaultValue,
|
||||
type = 'multiple',
|
||||
...baseProps
|
||||
}: BentoGridListCardProps) => {
|
||||
return (
|
||||
<BentoGridBaseCard {...baseProps} className={cn('flex flex-col', baseProps.className)}>
|
||||
<VStack className="h-full" gap={4}>
|
||||
<HStack align="center" gap={3}>
|
||||
{icon && <div>{icon}</div>}
|
||||
<Header level={3}>{title}</Header>
|
||||
</HStack>
|
||||
|
||||
{description && (
|
||||
<Text className="text-rb-muted-text">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="flex-grow w-full">
|
||||
<Accordion collapsible defaultValue={defaultValue} type={type}>
|
||||
{sections.map((section) => (
|
||||
<Accordion.Item key={section.value} value={section.value}>
|
||||
<Accordion.Trigger className={section.labelClassName}>
|
||||
<HStack align="center" gap={2}>
|
||||
{section.labelIcon}
|
||||
<Text size="l" type="body">{section.label}</Text>
|
||||
</HStack>
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<List>
|
||||
{section.items.map((item, index) => {
|
||||
const content = (() => {
|
||||
if (item && typeof item === 'object' && 'node' in item) {
|
||||
return item.node;
|
||||
}
|
||||
return item;
|
||||
})();
|
||||
|
||||
const itemKey =
|
||||
item && typeof item === 'object' && 'key' in item
|
||||
? item.key
|
||||
: typeof item === 'string'
|
||||
? item
|
||||
: index;
|
||||
|
||||
return (
|
||||
<List.Item key={itemKey}>
|
||||
<Text size="m">{content}</Text>
|
||||
</List.Item>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</VStack>
|
||||
</BentoGridBaseCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './BentoGrid';
|
||||
export * from './types';
|
||||
export * from './BentoGridBaseCard';
|
||||
export * from './BentoGridImageCard';
|
||||
export * from './BentoGridInfoCard';
|
||||
export * from './BentoGridCTACard';
|
||||
export * from './BentoGridListCard';
|
||||
@@ -0,0 +1,39 @@
|
||||
import type React from 'react';
|
||||
|
||||
/**
|
||||
* Common props for all BentoGrid card variations.
|
||||
*/
|
||||
export interface BentoCardBaseProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Number of columns to span on medium screens and up.
|
||||
* @default 1
|
||||
*/
|
||||
colSpan?: 1 | 2 | 3 | 4;
|
||||
/**
|
||||
* Number of rows to span on medium screens and up.
|
||||
* @default 1
|
||||
*/
|
||||
rowSpan?: 1 | 2 | 3;
|
||||
/**
|
||||
* Additional CSS classes.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* The content of the card.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the root BentoGrid component.
|
||||
*/
|
||||
export interface BentoGridProps {
|
||||
/**
|
||||
* The cards to display in the grid.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Additional CSS classes.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
@@ -33,6 +33,18 @@ export const Default: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: () => (
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="arrow" />
|
||||
<Breadcrumbs.Item onClick={() => alert('Clicked!')}>Click Me</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="arrow" />
|
||||
<Breadcrumbs.Item isCurrent>Current Page</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithArrows: Story = {
|
||||
render: () => (
|
||||
<Breadcrumbs>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Breadcrumbs } from './Breadcrumbs';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
import { render, screen, fireEvent } from '../../lib/testing';
|
||||
import { Link } from '../Link/Link';
|
||||
|
||||
describe('components/Breadcrumbs', () => {
|
||||
@@ -12,14 +12,58 @@ describe('components/Breadcrumbs', () => {
|
||||
<Link href="/">Home</Link>
|
||||
</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="slash" />
|
||||
<Breadcrumbs.Item>Current</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Item isCurrent>Current</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Breadcrumb');
|
||||
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
|
||||
expect(screen.getByText('/')).toBeInTheDocument();
|
||||
expect(screen.getByText('Current')).toBeInTheDocument();
|
||||
|
||||
const currentItem = screen.getByText('Current').parentElement;
|
||||
expect(currentItem).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('supports isCurrent state', () => {
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item isCurrent>Current Page</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const item = screen.getByText('Current Page').parentElement;
|
||||
expect(item).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('supports href directly on Item', () => {
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item href="/test-url">Test Link</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
expect(link).toHaveAttribute('href', '/en/test-url');
|
||||
});
|
||||
|
||||
it('supports onClick on Item', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item onClick={handleClick}>Clickable</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const button = screen.getByRole('button', { name: 'Clickable' });
|
||||
fireEvent.click(button);
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders as a non-submit button when onClick is provided', () => {
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item onClick={vi.fn()}>Clickable</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const button = screen.getByRole('button', { name: 'Clickable' });
|
||||
expect(button).toHaveAttribute('type', 'button');
|
||||
});
|
||||
|
||||
it('supports different separator variants', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
@@ -29,20 +30,58 @@ const BreadcrumbsRoot = memo(function Breadcrumbs({ children, ...props }: Breadc
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for the individual Breadcrumbs item.
|
||||
*/
|
||||
export interface BreadcrumbsItemProps extends TestIdProps {
|
||||
/** Content to be rendered inside the item */
|
||||
children: React.ReactNode;
|
||||
/** Optional className for the list item */
|
||||
className?: string;
|
||||
/** Whether this is the current page */
|
||||
isCurrent?: boolean;
|
||||
/** Optional href for the item (renders as a link) */
|
||||
href?: string;
|
||||
/** Optional click handler for the item */
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual step in the breadcrumb trail.
|
||||
*/
|
||||
const BreadcrumbsItem = memo(function BreadcrumbsItem({
|
||||
children,
|
||||
className,
|
||||
isCurrent,
|
||||
href,
|
||||
onClick,
|
||||
'data-testid': testId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
} & TestIdProps) {
|
||||
}: BreadcrumbsItemProps) {
|
||||
const resolvedHref = localePath(href);
|
||||
const isClickable = !!(resolvedHref ?? onClick);
|
||||
|
||||
return (
|
||||
<li className={className} data-testid={testId}>
|
||||
<Text color="muted" size="m" type="label">
|
||||
<li
|
||||
aria-current={isCurrent ? 'page' : undefined}
|
||||
className={cn(
|
||||
'group flex items-center transition-all duration-200',
|
||||
!isCurrent && isClickable && 'cursor-pointer',
|
||||
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'
|
||||
)}
|
||||
color={isCurrent ? 'default' : 'muted'}
|
||||
href={resolvedHref}
|
||||
size="m"
|
||||
type="label"
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</li>
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { Link } from './Link';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
|
||||
describe('components/Link', () => {
|
||||
beforeEach(() => {
|
||||
// Mock IntersectionObserver to avoid errors in Text component
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly as a link when href is provided', () => {
|
||||
render(<Link href="https://example.com">Click me</Link>);
|
||||
const link = screen.getByRole('link', { name: 'Click me' });
|
||||
@@ -44,4 +58,14 @@ describe('components/Link', () => {
|
||||
render(<Link onPress={() => {}}>Click me</Link>);
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes delay prop to the underlying Text component', () => {
|
||||
render(
|
||||
<Link delay={500} href="/test">
|
||||
Delayed Link
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('opacity-0');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { castRef, cn } from '@ermnvldmr/stl';
|
||||
import React, { memo, useRef } from 'react';
|
||||
import { useLink } from 'react-aria';
|
||||
@@ -24,6 +25,8 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
|
||||
size?: TextSize;
|
||||
/** Text color variant */
|
||||
color?: TextColor;
|
||||
/** Delay in milliseconds before the link appears after entering the viewport. */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +36,7 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
|
||||
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
|
||||
*/
|
||||
export const Link = memo(function Link(props: LinkProps) {
|
||||
const { children, className, href, isExternal, type, size, color, ...otherProps } = props;
|
||||
const { children, className, href, isExternal, type, size, color, delay, ...otherProps } = props;
|
||||
|
||||
// We use a specific ref type that useLink expects.
|
||||
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
|
||||
@@ -48,6 +51,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
);
|
||||
|
||||
if (href) {
|
||||
const resolvedHref = localePath(href);
|
||||
const isAutoExternal = isExternal ?? href.startsWith('http');
|
||||
const externalProps = isAutoExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
||||
|
||||
@@ -59,7 +63,8 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
as="a"
|
||||
className={sharedClasses}
|
||||
color={color}
|
||||
href={href}
|
||||
delay={delay}
|
||||
href={resolvedHref}
|
||||
size={size}
|
||||
type={type}
|
||||
>
|
||||
@@ -75,6 +80,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
as="span"
|
||||
className={sharedClasses}
|
||||
color={color}
|
||||
delay={delay}
|
||||
size={size}
|
||||
type={type}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('Markdown Components', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('Heading 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is a paragraph.')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is a paragraph.')).toHaveClass('mb-4');
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com');
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
|
||||
@@ -51,9 +51,6 @@ const MarkdownComponent = memo(function Markdown({ children, className }: Markdo
|
||||
* <Markdown.H1>Title</Markdown.H1>
|
||||
* <Markdown.P>Content goes here.</Markdown.P>
|
||||
* </Markdown>
|
||||
*
|
||||
* // Or with Astro:
|
||||
* <Content components={MARKDOWN_COMPONENTS} />
|
||||
* ```
|
||||
*/
|
||||
export const Markdown = Object.assign(MarkdownComponent, {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { List } from '../../List/List';
|
||||
import { MarkdownP } from '../MarkdownP/MarkdownP';
|
||||
|
||||
import type { ListItemProps } from '../../List/List';
|
||||
|
||||
/**
|
||||
* A Markdown list item component.
|
||||
*/
|
||||
export const MarkdownLI = memo(function MarkdownLI(props: ListItemProps) {
|
||||
return <List.Item {...props} />;
|
||||
export const MarkdownLI = memo(function MarkdownLI({ children, ...props }: ListItemProps) {
|
||||
return (
|
||||
<List.Item {...props}>
|
||||
<MarkdownP>{children}</MarkdownP>
|
||||
</List.Item>
|
||||
);
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import { MarkdownUL } from './MarkdownUL/MarkdownUL';
|
||||
import type React from 'react';
|
||||
|
||||
/**
|
||||
* A map of HTML tags to Markdown components for use with MDX or Astro Content.
|
||||
* A map of HTML tags to Markdown components for use with MDX or other Markdown systems.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const MARKDOWN_COMPONENTS: Record<string, React.ComponentType<any>> = {
|
||||
|
||||
@@ -33,6 +33,6 @@ describe('PageHead', () => {
|
||||
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header).toHaveClass('fixed');
|
||||
expect(header).toHaveClass('shadow-md');
|
||||
expect(header).not.toHaveClass('sticky');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn, useScroll, useResizeObserver } from '@ermnvldmr/stl';
|
||||
import React, { type ReactNode, useRef, useEffect, useState } from 'react';
|
||||
import { cn, useScroll } from '@ermnvldmr/stl';
|
||||
import React, { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import { Header } from '../../Header/Header';
|
||||
import { HStack } from '../../HStack/HStack';
|
||||
@@ -67,21 +67,29 @@ export const PageHead = ({
|
||||
className,
|
||||
}: PageHeadProps) => {
|
||||
const { y: scrollY } = useScroll();
|
||||
const [isCollapsed, setIsCollapsed] = useState(strategy.includes('always-collapsed'));
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const { height } = useResizeObserver(headerRef);
|
||||
const isCollapsible = strategy === 'collapsible-sticky';
|
||||
const isAlwaysCollapsed = strategy.startsWith('always-collapsed');
|
||||
const [isCollapsed, setIsCollapsed] = useState(isAlwaysCollapsed);
|
||||
|
||||
useEffect(() => {
|
||||
if (strategy === 'collapsible-sticky') {
|
||||
setIsCollapsed(scrollY > 10);
|
||||
if (isCollapsible) {
|
||||
// Trigger collapse once we've scrolled past the expanded header's height.
|
||||
// Usually around 150-200px depending on caption length.
|
||||
// 200px ensures it won't snap until it's mostly gone.
|
||||
setIsCollapsed(scrollY > 200);
|
||||
}
|
||||
}, [scrollY, strategy]);
|
||||
}, [scrollY, isCollapsible]);
|
||||
|
||||
const rootClasses = cn(
|
||||
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] fixed top-0 z-10 border-b border-[var(--rb-outline)]/10',
|
||||
{
|
||||
'shadow-md bg-[var(--rb-background)]/90 backdrop-blur-md': isCollapsed,
|
||||
},
|
||||
'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
|
||||
);
|
||||
|
||||
@@ -95,7 +103,7 @@ export const PageHead = ({
|
||||
</HStack>
|
||||
{addonRight && <div>{addonRight}</div>}
|
||||
</HStack>
|
||||
<VStack gap={2}>
|
||||
<VStack gap={4}>
|
||||
<Header level={1}>{heading}</Header>
|
||||
{typeof caption === 'string' ? (
|
||||
<Text color="muted" size="l" type="body">
|
||||
@@ -124,26 +132,31 @@ export const PageHead = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header ref={headerRef} className={rootClasses}>
|
||||
<header className={rootClasses}>
|
||||
{/* Expanded Content: In-flow for collapsible, or sticky/fixed for others */}
|
||||
{!isAlwaysCollapsed && (
|
||||
<div
|
||||
className={cn('transition-all duration-300', {
|
||||
'opacity-0 invisible h-0 overflow-hidden': isCollapsed,
|
||||
'opacity-100 visible': !isCollapsed,
|
||||
})}
|
||||
className={cn(
|
||||
'transition-opacity duration-300',
|
||||
isCollapsible && isCollapsed ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{expandedContent}
|
||||
</div>
|
||||
<div
|
||||
className={cn('transition-all duration-300', {
|
||||
'opacity-100 visible': isCollapsed,
|
||||
'opacity-0 invisible h-0 overflow-hidden': !isCollapsed,
|
||||
})}
|
||||
>
|
||||
{collapsedContent}
|
||||
</div>
|
||||
</header>
|
||||
<div style={{ height }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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,14 +1,90 @@
|
||||
import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { Text } from './Text';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
import { render, screen, act } from '../../lib/testing';
|
||||
|
||||
describe('components/Text', () => {
|
||||
let intersectionCallback: (entries: any[]) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
constructor(public callback: (entries: any[]) => void) {
|
||||
intersectionCallback = callback;
|
||||
}
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders children correctly', () => {
|
||||
render(<Text>Test content</Text>);
|
||||
expect(screen.getByText('Test content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is immediately visible when no delay is provided', () => {
|
||||
render(<Text>No delay</Text>);
|
||||
const el = screen.getByText('No delay');
|
||||
expect(el).not.toHaveClass('opacity-0');
|
||||
expect(el).not.toHaveClass('animate-in');
|
||||
});
|
||||
|
||||
it('is initially hidden when delay is provided', () => {
|
||||
render(<Text delay={500}>With delay</Text>);
|
||||
const el = screen.getByText('With delay');
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
});
|
||||
|
||||
it('becomes visible after intersection and specified delay', () => {
|
||||
render(<Text delay={500}>Delayed visible</Text>);
|
||||
const el = screen.getByText('Delayed visible');
|
||||
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
|
||||
// Simulate intersection
|
||||
act(() => {
|
||||
intersectionCallback([{ isIntersecting: true }]);
|
||||
});
|
||||
|
||||
// Should still be hidden before delay
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
|
||||
// Should be visible after delay
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
expect(el).not.toHaveClass('opacity-0');
|
||||
expect(el).toHaveClass('animate-in');
|
||||
expect(el).toHaveClass('fade-in');
|
||||
});
|
||||
|
||||
it('becomes visible immediately after intersection if delay is 0', () => {
|
||||
render(<Text delay={0}>Zero delay</Text>);
|
||||
const el = screen.getByText('Zero delay');
|
||||
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
|
||||
// Simulate intersection
|
||||
act(() => {
|
||||
intersectionCallback([{ isIntersecting: true }]);
|
||||
});
|
||||
|
||||
expect(el).not.toHaveClass('opacity-0');
|
||||
expect(el).toHaveClass('animate-in');
|
||||
});
|
||||
|
||||
it('applies alignment classes', () => {
|
||||
render(<Text align="center">Centered</Text>);
|
||||
expect(screen.getByText('Centered')).toHaveClass('text-center');
|
||||
@@ -39,4 +115,66 @@ describe('components/Text', () => {
|
||||
expect(el).toHaveClass('line-clamp-2');
|
||||
expect(el).not.toHaveClass('truncate');
|
||||
});
|
||||
|
||||
it('applies type="button" by default when as="button"', () => {
|
||||
render(<Text as="button">Button Text</Text>);
|
||||
expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
|
||||
});
|
||||
|
||||
it('allows overriding type attribute with htmlType', () => {
|
||||
render(
|
||||
<Text as="button" htmlType="submit">
|
||||
Submit Text
|
||||
</Text>
|
||||
);
|
||||
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
|
||||
// TDD: fails before implementation (old code uses text-[14px], not text-rb-body-m)
|
||||
it('applies text-rb-{type}-{size} class', () => {
|
||||
render(
|
||||
<Text size="m" type="body">
|
||||
Content
|
||||
</Text>
|
||||
);
|
||||
expect(screen.getByText('Content')).toHaveClass('text-rb-body-m');
|
||||
});
|
||||
|
||||
it('does not apply legacy px-based font-size classes', () => {
|
||||
render(
|
||||
<Text size="m" type="body">
|
||||
Content
|
||||
</Text>
|
||||
);
|
||||
expect(screen.getByText('Content')).not.toHaveClass('text-[14px]');
|
||||
expect(screen.getByText('Content')).not.toHaveClass('leading-[20px]');
|
||||
});
|
||||
|
||||
// Regression guards: pass before and after
|
||||
it('applies font-medium for label type', () => {
|
||||
render(
|
||||
<Text size="m" type="label">
|
||||
Label
|
||||
</Text>
|
||||
);
|
||||
expect(screen.getByText('Label')).toHaveClass('font-medium');
|
||||
});
|
||||
|
||||
it('applies font-sans for body type', () => {
|
||||
render(
|
||||
<Text size="m" type="body">
|
||||
Body
|
||||
</Text>
|
||||
);
|
||||
expect(screen.getByText('Body')).toHaveClass('font-sans');
|
||||
});
|
||||
|
||||
it('applies font-serif for display type', () => {
|
||||
render(
|
||||
<Text size="l" type="display">
|
||||
Display
|
||||
</Text>
|
||||
);
|
||||
expect(screen.getByText('Display')).toHaveClass('font-serif');
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,19 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { cn, genericMemo, useIntersectionObserver, castMutableRef } from '@ermnvldmr/stl';
|
||||
import React, { forwardRef, useState, useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
getTypographyClassNames,
|
||||
type TextType,
|
||||
type TextSize,
|
||||
type TextColor,
|
||||
type TextAlign,
|
||||
type TextWrap,
|
||||
type TextOverflow,
|
||||
} from '../../lib/typography';
|
||||
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
/**
|
||||
* Typography types.
|
||||
*/
|
||||
export type TextType = 'display' | 'headline' | 'title' | 'body' | 'label';
|
||||
|
||||
/**
|
||||
* Typography sizes.
|
||||
*/
|
||||
export type TextSize = 's' | 'm' | 'l';
|
||||
|
||||
/**
|
||||
* Text color variants.
|
||||
*/
|
||||
export type TextColor =
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'tertiary'
|
||||
| 'error'
|
||||
| 'muted'
|
||||
| 'inherit';
|
||||
export type { TextType, TextSize, TextColor, TextAlign, TextWrap, TextOverflow };
|
||||
|
||||
/**
|
||||
* Props for the Text component.
|
||||
@@ -53,20 +43,24 @@ export interface TextProps
|
||||
/** Optional href for links */
|
||||
href?: string;
|
||||
/** Text alignment */
|
||||
align?: 'left' | 'center' | 'right' | 'justify';
|
||||
align?: TextAlign;
|
||||
/** Text wrapping behavior */
|
||||
wrap?: 'nowrap' | 'balance' | 'pretty';
|
||||
wrap?: TextWrap;
|
||||
/** Overflow behavior for single lines */
|
||||
overflow?: 'ellipsis' | 'clip';
|
||||
overflow?: TextOverflow;
|
||||
/** Maximum number of lines to show (truncates with ellipsis) */
|
||||
maxLines?: number;
|
||||
/** Delay in milliseconds before the text appears after entering the viewport. */
|
||||
delay?: number;
|
||||
/** Underlying element's HTML type attribute (e.g. "button", "submit") */
|
||||
htmlType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A flexible typography component that implements the design system's type scale.
|
||||
*
|
||||
* Text centralizes all typography rules, including font families, sizes,
|
||||
* line heights, and letter spacing, reducing the need for custom CSS.
|
||||
* Text centralizes all typography rules — font families, sizes, line heights,
|
||||
* letter spacing, colors, and decorations — via the getTypographyClassNames utility.
|
||||
*/
|
||||
const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
{
|
||||
@@ -82,90 +76,70 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
wrap,
|
||||
overflow,
|
||||
maxLines,
|
||||
delay,
|
||||
as: Component = 'span',
|
||||
htmlType,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
// Base typography styles mapping
|
||||
const typeStyles: Record<TextType, Record<TextSize, string>> = {
|
||||
display: {
|
||||
l: 'font-serif text-[57px] leading-[64px] tracking-[-0.25px]',
|
||||
m: 'font-serif text-[45px] leading-[52px] tracking-normal',
|
||||
s: 'font-serif text-[36px] leading-[44px] tracking-normal',
|
||||
},
|
||||
headline: {
|
||||
l: 'font-serif text-[32px] leading-[40px] tracking-normal',
|
||||
m: 'font-serif text-[28px] leading-[36px] tracking-normal',
|
||||
s: 'font-serif text-[24px] leading-[32px] tracking-normal',
|
||||
},
|
||||
title: {
|
||||
l: 'font-sans text-[22px] leading-[28px] tracking-normal',
|
||||
m: 'font-sans text-[16px] leading-[24px] tracking-[0.15px]',
|
||||
s: 'font-sans text-[14px] leading-[20px] tracking-[0.1px]',
|
||||
},
|
||||
body: {
|
||||
l: 'font-sans text-[16px] leading-[24px] tracking-[0.5px]',
|
||||
m: 'font-sans text-[14px] leading-[20px] tracking-[0.25px]',
|
||||
s: 'font-sans text-[12px] leading-[16px] tracking-[0.4px]',
|
||||
},
|
||||
label: {
|
||||
l: 'font-sans font-medium text-[14px] leading-[20px] tracking-[0.1px]',
|
||||
m: 'font-sans font-medium text-[12px] leading-[16px] tracking-[0.5px]',
|
||||
s: 'font-sans font-medium text-[11px] leading-[16px] tracking-[0.5px]',
|
||||
},
|
||||
};
|
||||
const [isVisible, setIsVisible] = useState(delay === undefined);
|
||||
const internalRef = useRef<HTMLElement>(null);
|
||||
|
||||
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',
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!ref) return;
|
||||
if (typeof ref === 'function') {
|
||||
ref(internalRef.current);
|
||||
} else {
|
||||
castMutableRef<HTMLElement>(ref).current = internalRef.current;
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
const alignClasses = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
justify: 'text-justify',
|
||||
};
|
||||
const isIntersecting = useIntersectionObserver(internalRef, {
|
||||
threshold: 0.1,
|
||||
once: true,
|
||||
});
|
||||
|
||||
const wrapClasses = {
|
||||
nowrap: 'whitespace-nowrap',
|
||||
balance: 'text-balance',
|
||||
pretty: 'text-pretty',
|
||||
};
|
||||
|
||||
let truncationClass = '';
|
||||
if (maxLines) {
|
||||
truncationClass = `line-clamp-${maxLines}`;
|
||||
} else {
|
||||
if (overflow === 'ellipsis') truncationClass = 'truncate';
|
||||
if (overflow === 'clip') truncationClass = 'overflow-clip';
|
||||
}
|
||||
useEffect(() => {
|
||||
if (delay !== undefined && isIntersecting) {
|
||||
if (delay > 0) {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [delay, isIntersecting]);
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
ref={castRef<HTMLElement>(ref)}
|
||||
ref={internalRef}
|
||||
className={cn(
|
||||
typeStyles[type][size],
|
||||
colorClasses[color],
|
||||
bold && 'font-bold',
|
||||
italic && 'italic',
|
||||
underline && 'underline',
|
||||
strike && 'line-through',
|
||||
align && alignClasses[align],
|
||||
wrap && wrapClasses[wrap],
|
||||
truncationClass,
|
||||
getTypographyClassNames({
|
||||
type,
|
||||
size,
|
||||
color,
|
||||
bold,
|
||||
italic,
|
||||
underline,
|
||||
strike,
|
||||
align,
|
||||
wrap,
|
||||
overflow,
|
||||
maxLines,
|
||||
}),
|
||||
delay !== undefined &&
|
||||
(isVisible
|
||||
? 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out'
|
||||
: 'opacity-0'),
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
type={htmlType ?? (Component === 'button' ? 'button' : undefined)}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './Accordion/index';
|
||||
export * from './Article/Article';
|
||||
export * from './BentoGrid/index';
|
||||
export * from './Blockquote/Blockquote';
|
||||
export * from './Breadcrumbs/Breadcrumbs';
|
||||
export * from './Button/Button';
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './theme';
|
||||
export * from './typography';
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
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 correct class for each type/size combination', () => {
|
||||
expect(getTypographyClassNames({ type: 'display', size: 'l' })).toContain(
|
||||
'text-rb-display-l'
|
||||
);
|
||||
expect(getTypographyClassNames({ type: 'headline', size: 'm' })).toContain(
|
||||
'text-rb-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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('font family', () => {
|
||||
it('applies font-serif for display and headline', () => {
|
||||
expect(getTypographyClassNames({ type: 'display' })).toContain('font-serif');
|
||||
expect(getTypographyClassNames({ type: 'headline' })).toContain('font-serif');
|
||||
});
|
||||
|
||||
it('applies font-sans for title, body, and label', () => {
|
||||
expect(getTypographyClassNames({ type: 'title' })).toContain('font-sans');
|
||||
expect(getTypographyClassNames({ type: 'body' })).toContain('font-sans');
|
||||
expect(getTypographyClassNames({ type: 'label' })).toContain('font-sans');
|
||||
});
|
||||
});
|
||||
|
||||
describe('font weight', () => {
|
||||
it('adds font-medium for label type', () => {
|
||||
expect(getTypographyClassNames({ type: 'label' })).toContain('font-medium');
|
||||
});
|
||||
|
||||
it('does not add font-medium for non-label types', () => {
|
||||
expect(getTypographyClassNames({ type: 'body' })).not.toContain('font-medium');
|
||||
expect(getTypographyClassNames({ type: 'title' })).not.toContain('font-medium');
|
||||
});
|
||||
|
||||
it('adds font-bold when bold is true', () => {
|
||||
expect(getTypographyClassNames({ bold: true })).toContain('font-bold');
|
||||
});
|
||||
|
||||
it('does not add font-bold by default', () => {
|
||||
expect(getTypographyClassNames()).not.toContain('font-bold');
|
||||
});
|
||||
});
|
||||
|
||||
describe('color', () => {
|
||||
it('applies default color class', () => {
|
||||
expect(getTypographyClassNames()).toContain('text-[var(--rb-text)]');
|
||||
});
|
||||
|
||||
it('applies each color variant', () => {
|
||||
expect(getTypographyClassNames({ color: 'muted' })).toContain('text-[var(--rb-muted-text)]');
|
||||
expect(getTypographyClassNames({ color: 'primary' })).toContain(
|
||||
'text-[var(--rb-primary-text)]'
|
||||
);
|
||||
expect(getTypographyClassNames({ color: 'error' })).toContain('text-[var(--rb-error-text)]');
|
||||
expect(getTypographyClassNames({ color: 'inherit' })).toContain('text-inherit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decorations', () => {
|
||||
it('applies italic', () => {
|
||||
expect(getTypographyClassNames({ italic: true })).toContain('italic');
|
||||
});
|
||||
|
||||
it('applies underline', () => {
|
||||
expect(getTypographyClassNames({ underline: true })).toContain('underline');
|
||||
});
|
||||
|
||||
it('applies line-through for strike', () => {
|
||||
expect(getTypographyClassNames({ strike: true })).toContain('line-through');
|
||||
});
|
||||
|
||||
it('does not apply decorations by default', () => {
|
||||
const result = getTypographyClassNames();
|
||||
expect(result).not.toContain('italic');
|
||||
expect(result).not.toContain('underline');
|
||||
expect(result).not.toContain('line-through');
|
||||
});
|
||||
});
|
||||
|
||||
describe('alignment', () => {
|
||||
it('applies text-center', () => {
|
||||
expect(getTypographyClassNames({ align: 'center' })).toContain('text-center');
|
||||
});
|
||||
|
||||
it('applies each alignment', () => {
|
||||
expect(getTypographyClassNames({ align: 'left' })).toContain('text-left');
|
||||
expect(getTypographyClassNames({ align: 'right' })).toContain('text-right');
|
||||
expect(getTypographyClassNames({ align: 'justify' })).toContain('text-justify');
|
||||
});
|
||||
|
||||
it('does not apply alignment by default', () => {
|
||||
const result = getTypographyClassNames();
|
||||
expect(result).not.toMatch(/text-(left|center|right|justify)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrap', () => {
|
||||
it('applies whitespace-nowrap', () => {
|
||||
expect(getTypographyClassNames({ wrap: 'nowrap' })).toContain('whitespace-nowrap');
|
||||
});
|
||||
|
||||
it('applies text-balance', () => {
|
||||
expect(getTypographyClassNames({ wrap: 'balance' })).toContain('text-balance');
|
||||
});
|
||||
|
||||
it('applies text-pretty', () => {
|
||||
expect(getTypographyClassNames({ wrap: 'pretty' })).toContain('text-pretty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('overflow / maxLines', () => {
|
||||
it('applies truncate for overflow ellipsis', () => {
|
||||
expect(getTypographyClassNames({ overflow: 'ellipsis' })).toContain('truncate');
|
||||
});
|
||||
|
||||
it('applies overflow-clip for overflow clip', () => {
|
||||
expect(getTypographyClassNames({ overflow: 'clip' })).toContain('overflow-clip');
|
||||
});
|
||||
|
||||
it('applies line-clamp for maxLines', () => {
|
||||
expect(getTypographyClassNames({ maxLines: 3 })).toContain('line-clamp-3');
|
||||
});
|
||||
|
||||
it('maxLines takes precedence over overflow', () => {
|
||||
const result = getTypographyClassNames({ maxLines: 2, overflow: 'ellipsis' });
|
||||
expect(result).toContain('line-clamp-2');
|
||||
expect(result).not.toContain('truncate');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Typography type roles.
|
||||
* Determines font family and base visual treatment.
|
||||
*/
|
||||
export type TextType = 'display' | 'headline' | 'title' | 'body' | 'label';
|
||||
|
||||
/**
|
||||
* Typography size scale.
|
||||
* Applied within a type role to select the appropriate @theme token.
|
||||
*/
|
||||
export type TextSize = 's' | 'm' | 'l';
|
||||
|
||||
/**
|
||||
* Text color variants mapped to design-system CSS custom properties.
|
||||
*/
|
||||
export type TextColor =
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'tertiary'
|
||||
| 'error'
|
||||
| 'muted'
|
||||
| 'inherit';
|
||||
|
||||
/**
|
||||
* Text alignment.
|
||||
*/
|
||||
export type TextAlign = 'left' | 'center' | 'right' | 'justify';
|
||||
|
||||
/**
|
||||
* Text wrapping behaviour.
|
||||
*/
|
||||
export type TextWrap = 'nowrap' | 'balance' | 'pretty';
|
||||
|
||||
/**
|
||||
* Overflow handling for single-line text.
|
||||
*/
|
||||
export type TextOverflow = 'ellipsis' | 'clip';
|
||||
|
||||
/**
|
||||
* Full set of typography options supported by getTypographyClassNames.
|
||||
*/
|
||||
export interface TypographyOptions {
|
||||
/** Typography role — determines font family and base style. */
|
||||
type?: TextType;
|
||||
/** Size scale within the role. */
|
||||
size?: TextSize;
|
||||
/** Text color variant. */
|
||||
color?: TextColor;
|
||||
/** Bold weight. */
|
||||
bold?: boolean;
|
||||
/** Italic style. */
|
||||
italic?: boolean;
|
||||
/** Underline decoration. */
|
||||
underline?: boolean;
|
||||
/** Strikethrough decoration. */
|
||||
strike?: boolean;
|
||||
/** Text alignment. */
|
||||
align?: TextAlign;
|
||||
/** Text wrapping behaviour. */
|
||||
wrap?: TextWrap;
|
||||
/** Overflow truncation for single lines. */
|
||||
overflow?: TextOverflow;
|
||||
/** Maximum visible lines (truncates with ellipsis). Takes precedence over overflow. */
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
const fontFamilyClasses: Record<TextType, string> = {
|
||||
display: 'font-serif',
|
||||
headline: 'font-serif',
|
||||
title: 'font-sans',
|
||||
body: 'font-sans',
|
||||
label: 'font-sans',
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a space-separated string of Tailwind CSS classes for the given
|
||||
* typography options.
|
||||
*
|
||||
* 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
|
||||
* font-size group so it never conflicts with text-color classes.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In a component:
|
||||
* <p className={cn(getTypographyClassNames({ type: 'body', size: 'l' }), className)}>
|
||||
*
|
||||
* // In a CSS-cascade parent/child pattern:
|
||||
* <div className="rb-scale-l">
|
||||
* <p className={cn(getTypographyClassNames({ type: 'body' }))}>…</p>
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
export function getTypographyClassNames(options: TypographyOptions = {}): string {
|
||||
const {
|
||||
type = 'body',
|
||||
size = 'm',
|
||||
color = 'default',
|
||||
bold = false,
|
||||
italic = false,
|
||||
underline = false,
|
||||
strike = false,
|
||||
align,
|
||||
wrap,
|
||||
overflow,
|
||||
maxLines,
|
||||
} = options;
|
||||
|
||||
let truncationClass = '';
|
||||
if (maxLines) {
|
||||
truncationClass = `line-clamp-${maxLines}`;
|
||||
} else if (overflow === 'ellipsis') {
|
||||
truncationClass = 'truncate';
|
||||
} else if (overflow === 'clip') {
|
||||
truncationClass = 'overflow-clip';
|
||||
}
|
||||
|
||||
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(' ');
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './getTypographyClassNames/getTypographyClassNames';
|
||||
@@ -76,6 +76,11 @@
|
||||
@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 *));
|
||||
|
||||
@theme {
|
||||
@@ -184,6 +189,66 @@
|
||||
--rb-color-neutral-975: #141414;
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* display */
|
||||
--text-rb-display-l: 3.5625rem;
|
||||
--text-rb-display-l--line-height: 4rem;
|
||||
--text-rb-display-l--letter-spacing: -0.015625rem;
|
||||
--text-rb-display-m: 2.8125rem;
|
||||
--text-rb-display-m--line-height: 3.25rem;
|
||||
--text-rb-display-m--letter-spacing: 0rem;
|
||||
--text-rb-display-s: 2.25rem;
|
||||
--text-rb-display-s--line-height: 2.75rem;
|
||||
--text-rb-display-s--letter-spacing: 0rem;
|
||||
|
||||
/* headline */
|
||||
--text-rb-headline-l: 2rem;
|
||||
--text-rb-headline-l--line-height: 2.5rem;
|
||||
--text-rb-headline-l--letter-spacing: 0rem;
|
||||
--text-rb-headline-m: 1.75rem;
|
||||
--text-rb-headline-m--line-height: 2.25rem;
|
||||
--text-rb-headline-m--letter-spacing: 0rem;
|
||||
--text-rb-headline-s: 1.5rem;
|
||||
--text-rb-headline-s--line-height: 2rem;
|
||||
--text-rb-headline-s--letter-spacing: 0rem;
|
||||
|
||||
/* title */
|
||||
--text-rb-title-l: 1.375rem;
|
||||
--text-rb-title-l--line-height: 1.75rem;
|
||||
--text-rb-title-l--letter-spacing: 0rem;
|
||||
--text-rb-title-m: 1rem;
|
||||
--text-rb-title-m--line-height: 1.5rem;
|
||||
--text-rb-title-m--letter-spacing: 0.009375rem;
|
||||
--text-rb-title-s: 0.875rem;
|
||||
--text-rb-title-s--line-height: 1.25rem;
|
||||
--text-rb-title-s--letter-spacing: 0.00625rem;
|
||||
|
||||
/* body — baseline raised: m = 1rem (was 14px/0.875rem) */
|
||||
--text-rb-body-l: 1.125rem;
|
||||
--text-rb-body-l--line-height: 1.75rem;
|
||||
--text-rb-body-l--letter-spacing: 0.03125rem;
|
||||
--text-rb-body-m: 1rem;
|
||||
--text-rb-body-m--line-height: 1.5rem;
|
||||
--text-rb-body-m--letter-spacing: 0.015625rem;
|
||||
--text-rb-body-s: 0.875rem;
|
||||
--text-rb-body-s--line-height: 1.25rem;
|
||||
--text-rb-body-s--letter-spacing: 0.025rem;
|
||||
|
||||
/* label */
|
||||
--text-rb-label-l: 0.875rem;
|
||||
--text-rb-label-l--line-height: 1.25rem;
|
||||
--text-rb-label-l--letter-spacing: 0.00625rem;
|
||||
--text-rb-label-l--font-weight: 500;
|
||||
--text-rb-label-m: 0.75rem;
|
||||
--text-rb-label-m--line-height: 1rem;
|
||||
--text-rb-label-m--letter-spacing: 0.03125rem;
|
||||
--text-rb-label-m--font-weight: 500;
|
||||
--text-rb-label-s: 0.6875rem;
|
||||
--text-rb-label-s--line-height: 1rem;
|
||||
--text-rb-label-s--letter-spacing: 0.03125rem;
|
||||
--text-rb-label-s--font-weight: 500;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Core palette */
|
||||
--rb-base: var(--rb-color-neutral-50);
|
||||
@@ -250,6 +315,75 @@
|
||||
--rb-chart-5: var(--rb-color-red-400);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ── Scale parents — set CSS vars for all type children below ── */
|
||||
.rb-scale-s {
|
||||
--rb-sz-display: var(--text-rb-display-s);
|
||||
--rb-lh-display: var(--text-rb-display-s--line-height);
|
||||
--rb-sz-headline: var(--text-rb-headline-s);
|
||||
--rb-lh-headline: var(--text-rb-headline-s--line-height);
|
||||
--rb-sz-title: var(--text-rb-title-s);
|
||||
--rb-lh-title: var(--text-rb-title-s--line-height);
|
||||
--rb-sz-body: var(--text-rb-body-s);
|
||||
--rb-lh-body: var(--text-rb-body-s--line-height);
|
||||
--rb-sz-label: var(--text-rb-label-s);
|
||||
--rb-lh-label: var(--text-rb-label-s--line-height);
|
||||
}
|
||||
.rb-scale-m {
|
||||
--rb-sz-display: var(--text-rb-display-m);
|
||||
--rb-lh-display: var(--text-rb-display-m--line-height);
|
||||
--rb-sz-headline: var(--text-rb-headline-m);
|
||||
--rb-lh-headline: var(--text-rb-headline-m--line-height);
|
||||
--rb-sz-title: var(--text-rb-title-m);
|
||||
--rb-lh-title: var(--text-rb-title-m--line-height);
|
||||
--rb-sz-body: var(--text-rb-body-m);
|
||||
--rb-lh-body: var(--text-rb-body-m--line-height);
|
||||
--rb-sz-label: var(--text-rb-label-m);
|
||||
--rb-lh-label: var(--text-rb-label-m--line-height);
|
||||
}
|
||||
.rb-scale-l {
|
||||
--rb-sz-display: var(--text-rb-display-l);
|
||||
--rb-lh-display: var(--text-rb-display-l--line-height);
|
||||
--rb-sz-headline: var(--text-rb-headline-l);
|
||||
--rb-lh-headline: var(--text-rb-headline-l--line-height);
|
||||
--rb-sz-title: var(--text-rb-title-l);
|
||||
--rb-lh-title: var(--text-rb-title-l--line-height);
|
||||
--rb-sz-body: var(--text-rb-body-l);
|
||||
--rb-lh-body: var(--text-rb-body-l--line-height);
|
||||
--rb-sz-label: var(--text-rb-label-l);
|
||||
--rb-lh-label: var(--text-rb-label-l--line-height);
|
||||
}
|
||||
|
||||
/* ── Type children — declare type, read from parent scale vars ── */
|
||||
/* Fallback (second arg to var()) applies m-size when no rb-scale-* parent exists */
|
||||
.rb-text-display {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--rb-sz-display, var(--text-rb-display-m));
|
||||
line-height: var(--rb-lh-display, var(--text-rb-display-m--line-height));
|
||||
}
|
||||
.rb-text-headline {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--rb-sz-headline, var(--text-rb-headline-m));
|
||||
line-height: var(--rb-lh-headline, var(--text-rb-headline-m--line-height));
|
||||
}
|
||||
.rb-text-title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--rb-sz-title, var(--text-rb-title-m));
|
||||
line-height: var(--rb-lh-title, var(--text-rb-title-m--line-height));
|
||||
}
|
||||
.rb-text-body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--rb-sz-body, var(--text-rb-body-m));
|
||||
line-height: var(--rb-lh-body, var(--text-rb-body-m--line-height));
|
||||
}
|
||||
.rb-text-label {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
font-size: var(--rb-sz-label, var(--text-rb-label-m));
|
||||
line-height: var(--rb-lh-label, var(--text-rb-label-m--line-height));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
|
||||
Generated
+2564
-513
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,6 @@ title: "Ermnvldmr's Documentation"
|
||||
Welcome to the unified documentation for my own projects
|
||||
|
||||
{{< cards >}}
|
||||
{{< card link="root" title="root.ermnvldmr.com" icon="server" subtitle="Documentation for root.ermnvldmr.com infrastructure" >}}
|
||||
{{< card link="root" title="ermnvldmr.com-root-commons" icon="server" subtitle="Documentation for the root deployment commons framework" >}}
|
||||
{{< card link="quetza" title="Quetza Bot" icon="terminal" subtitle="Modular Discord bot framework documentation" >}}
|
||||
{{< /cards >}}
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Conversational AI Integration
|
||||
weight: 2
|
||||
weight: 4
|
||||
---
|
||||
|
||||
**Goal:** Integrate Large Language Models (LLMs) while managing context and Discord's timeout constraints.
|
||||
@@ -51,5 +51,6 @@ export async function execute(interaction, controller) {
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING] > **API Costs:**
|
||||
> [!WARNING]
|
||||
> **API Costs:**
|
||||
> AI APIs are often billed by token. Implement strict user-based rate limits in your Controller to prevent abuse.
|
||||
@@ -65,7 +65,8 @@ Once running, use these mechanisms to check health:
|
||||
- **`/ping` Command:** Verifies network latency and API responsiveness.
|
||||
- **`/modules` Command:** Lists all loaded modules to confirm feature availability.
|
||||
|
||||
> [!WARNING] > **Scaling Constraint:**
|
||||
> [!WARNING]
|
||||
> **Scaling Constraint:**
|
||||
> Quetza follows a single-instance-per-token model. Do not run multiple instances with the same token, as this will cause WebSocket conflicts.
|
||||
|
||||
---
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Interactive Voice Features
|
||||
weight: 1
|
||||
weight: 3
|
||||
---
|
||||
|
||||
**Goal:** Implement sophisticated voice interactions that respect guild boundaries and manage ephemeral resources.
|
||||
@@ -32,7 +32,8 @@ Audio sessions are ephemeral.
|
||||
- **Transient State:** Active audio stream (Lost on disconnect).
|
||||
- **Durable State:** Queue/Playlist (Persisted in Controller until restart).
|
||||
|
||||
> [!TIP] > **Resilience:** Separate the _Queue_ logic from the _Voice Connection_ logic. This allows the bot to reconnect without losing the user's playlist.
|
||||
> [!TIP]
|
||||
> **Resilience:** Separate the _Queue_ logic from the _Voice Connection_ logic. This allows the bot to reconnect without losing the user's playlist.
|
||||
|
||||
## 4. Implementation Sketch
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
title: Recipes
|
||||
weight: 3
|
||||
---
|
||||
|
||||
**Quetza** recipes are strategic architectural patterns for common functional requirements. They focus on _how_ to compose framework features to solve business problems.
|
||||
|
||||
## Recipe Categories
|
||||
|
||||
{{% cards %}}
|
||||
{{< card link="voice-features" title="Interactive Voice Features" icon="users" subtitle="Managing audio players and guild-scoped state." >}}
|
||||
{{< card link="conversational-ai" title="Conversational AI" icon="chip" subtitle="Handling LLM context and long-running responses." >}}
|
||||
{{% /cards %}}
|
||||
@@ -22,18 +22,15 @@ weight: 1
|
||||
|
||||
**Symptom:** `/command` does not appear in Discord.
|
||||
|
||||
### Global vs. Guild Registration
|
||||
### Global vs. Test Guild Registration
|
||||
|
||||
- **Global:** Takes up to 1 hour to propagate.
|
||||
- **Guild:** Instant update.
|
||||
- **Fix:** Set `DEV_GUILD_ID` in your `.env` for development.
|
||||
- **Global:** Commands pushed to the application globally can take up to 1 hour to propagate and are subject to stricter rate limits.
|
||||
- **Test Guild:** Quetza automatically pushes commands to a specific guild in development mode for instant updates.
|
||||
- **Requirement:** Ensure `NODE_ENV=development` and `DEV_GUILD_ID` is correctly set in your `.env`.
|
||||
|
||||
### Data Malformation
|
||||
### Rate Limits
|
||||
|
||||
Discord rejects invalid command structures silently or with generic errors.
|
||||
|
||||
- **Name:** Must be lowercase, alphanumeric, `-` only.
|
||||
- **Description:** Required, max 100 chars.
|
||||
Frequent updates to global commands may trigger Discord's rate limits. Use a test guild for iterative development.
|
||||
|
||||
## 3. Interaction Failures
|
||||
|
||||
@@ -71,5 +68,6 @@ Check your Controller.
|
||||
- **Multiple:** Events are additive. Did you register the same handler in two modules?
|
||||
- **None:** Did you export `name` and `execute` correctly?
|
||||
|
||||
> [!TIP] > **Debug Mode:**
|
||||
> [!TIP]
|
||||
> **Debug Mode:**
|
||||
> Enable verbose logging to see exactly which modules and commands are loaded at startup.
|
||||
@@ -1,22 +1,33 @@
|
||||
---
|
||||
title: 'Root Infrastructure'
|
||||
description: 'Documentation for the root.ermnvldmr.com infrastructure.'
|
||||
title: Documentation of ermnvldmr.com root deployment commons
|
||||
description: Documentation for the ermnvldmr.com root deployment commons framework.
|
||||
weight: 1
|
||||
type: docs
|
||||
cascade:
|
||||
type: docs
|
||||
---
|
||||
|
||||
Comprehensive documentation for the personal infrastructure deployment and management, including setup guides and operational recipes.
|
||||
The `ermnvldmr.com-root-commons` framework provides a minimal-dependency deployment environment for independent, self-hosted projects.
|
||||
|
||||
The generic logic, scripts, and deployment patterns used across multiple unrelated self-hosted applications are stored in the [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons) repository.
|
||||
|
||||
## Core features
|
||||
|
||||
- **Smart Dispatcher**: A single entrypoint (`root`) for all orchestration tasks.
|
||||
- **Tiered Storage**: Standardized classification for configuration, data, and logs.
|
||||
- **Privilege Isolation**: Project-specific non-root users for enhanced security.
|
||||
- **Namespaced Libraries**: Explicit, modular Bash utilities for consistent automation.
|
||||
|
||||
## Sections
|
||||
|
||||
{{% cards %}}
|
||||
{{< card link="recipes" title="Recipes" icon="beaker" subtitle="Collection of step-by-step procedures for infrastructure maintenance and deployment." >}}
|
||||
{{< card link="concepts" title="Concepts" icon="book-open" subtitle="Theoretical foundations, architecture, and security models." >}}
|
||||
{{< card link="guides" title="Guides" icon="map" subtitle="Step-by-step guides for installation and action development." >}}
|
||||
{{< card link="reference" title="Reference" icon="code" subtitle="API and technical references for developers." >}}
|
||||
{{% /cards %}}
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [GitHub: root.ermnvldmr.com](https://github.com/deytenit/root.ermnvldmr.com)
|
||||
- [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons)
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: Concepts
|
||||
description: Theoretical foundations, architecture, and security models.
|
||||
weight: 10
|
||||
---
|
||||
|
||||
Theoretical foundations, architecture, and security models for the ermnvldmr.com root deployment commons framework.
|
||||
|
||||
{{% cards %}}
|
||||
{{< card link="principles" title="Infrastructure principles" icon="book-open" subtitle="Predictability, minimal dependencies, and decentralization." >}}
|
||||
{{< card link="architecture" title="Infrastructure architecture" icon="share" subtitle="Decentralized orchestration and deployment models." >}}
|
||||
{{< card link="security-model" title="Security & privilege isolation" icon="shield-check" subtitle="Privilege isolation and network hardening." >}}
|
||||
{{< card link="tiered-storage" title="Tiered storage model" icon="database" subtitle="Standardized storage classification for service data." >}}
|
||||
{{% /cards %}}
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Infrastructure architecture
|
||||
description: The decentralized orchestration model and deployment structure.
|
||||
weight: 10
|
||||
---
|
||||
|
||||
The Root framework operates on a decentralized orchestration model designed for independent, self-contained projects. It enforces a strict separation between the orchestrator (the "brain") and the automation tasks (the "muscle").
|
||||
|
||||
## Core philosophy {#philosophy}
|
||||
|
||||
The framework is built on the **Smart Root, Stupid Actions** principle. This design ensures that individual automation scripts remain declarative and unaware of the global system state, while the central dispatcher handles environment resolution and library initialization.
|
||||
|
||||
### Smart Root (The Dispatcher)
|
||||
|
||||
The `root` entrypoint is the sole interface for users. It is responsible for:
|
||||
|
||||
- **Environment resolution** → Locating the `commons` and `configs` repositories.
|
||||
- **Library warming** → Sourcing namespaced modules exactly once.
|
||||
- **Context establishment** → Defining the target [Node](/root/glossary#node) and its associated paths.
|
||||
|
||||
### Stupid Actions (The Tasks)
|
||||
|
||||
[Actions](/root/glossary#action) are declarative scripts located in `scripts/actions/`. They are "stupid" because:
|
||||
|
||||
- They do not calculate paths or detect repositories.
|
||||
- They depend entirely on the `ROOT_` environment variables.
|
||||
- They focus exclusively on a single atomic task (e.g., `compose`, `ufw`).
|
||||
|
||||
## Deployment model {#deployment}
|
||||
|
||||
The framework is typically deployed as a shared [Commons](/root/glossary#commons) repository mounted as a Git submodule within a configuration repository. The engine powering this shared infrastructure is hosted at [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons).
|
||||
|
||||
{{< filetree/container >}}
|
||||
{{< filetree/folder name="configs-repo" state="open" >}}
|
||||
{{< filetree/folder name=".operator" state="open" >}}
|
||||
{{< filetree/folder name="shared (submodule)" state="open" >}}
|
||||
{{< filetree/folder name="scripts" >}}
|
||||
{{< filetree/folder name="run" >}}
|
||||
{{< filetree/file name="root (dispatcher)" >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< filetree/folder name="lib" >}}
|
||||
{{< filetree/file name="shared libraries" >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< filetree/folder name="actions" >}}
|
||||
{{< filetree/file name="shared actions" >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< filetree/folder name="local" state="open" >}}
|
||||
{{< filetree/folder name="scripts" >}}
|
||||
{{< filetree/folder name="actions" >}}
|
||||
{{< filetree/file name="node-specific overrides" >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< filetree/folder name="daedalus (node)" >}}
|
||||
{{< filetree/file name="docker-compose.yml" >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< filetree/file name="init.sh (bootstrap)" >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/container >}}
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: The ROOT_ API](/root/reference/root-api)
|
||||
- [Guide: Installation](/root/guides/installation)
|
||||
- [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons)
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: Infrastructure principles
|
||||
description: Core principles providing a secure, predictable, and minimal-dependency environment.
|
||||
weight: 5
|
||||
---
|
||||
|
||||
The Root framework is built on core principles designed to provide a secure, predictable, and minimal-dependency environment for independent self-hosted projects.
|
||||
|
||||
## Predictability through structure {#predictability}
|
||||
|
||||
Manual server configuration is prone to "configuration drift," where hosts intended to be identical slowly diverge. Root solves this by:
|
||||
|
||||
- **Git-centric truth**: Every configuration, from UFW rules to crontabs, is stored in the repository.
|
||||
- **Idempotent automation**: [Actions](/root/glossary#action) are designed to be run multiple times without causing side effects, ensuring the host always matches the intended state.
|
||||
|
||||
## Minimal dependency footprint {#minimalism}
|
||||
|
||||
To ensure long-term maintainability, the framework avoids complex configuration management tools (like Ansible or Salt). Instead, it relies on:
|
||||
|
||||
- **Bash**: The universal language of Unix systems.
|
||||
- **Docker**: For application isolation and environment consistency.
|
||||
- **Standard Linux tools**: Leveraging `systemd`, `ufw`, and `cron` directly.
|
||||
|
||||
## Decentralization {#decentralization}
|
||||
|
||||
Each [Node](/root/glossary#node) is an island. While they share the [Commons](/root/glossary#commons) library, they do not depend on a central "master" server. This architecture ensures that a failure in one host does not impact unrelated services.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Security model](/root/concepts/security-model)
|
||||
- [Concept: Tiered storage](/root/concepts/tiered-storage)
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Security & privilege isolation
|
||||
description: Security measures, privilege isolation, and project-level sandboxing.
|
||||
weight: 20
|
||||
---
|
||||
|
||||
Security in Root is achieved through strict privilege isolation and project-level "sandboxing" to mitigate the impact of service compromises.
|
||||
|
||||
## The "No-Root" Principle {#noroot}
|
||||
|
||||
Running applications as `root` inside containers is a significant security risk. The framework enforces isolation via the `tiers/useradd` and `tiers/chown` actions.
|
||||
|
||||
### Project-specific users
|
||||
|
||||
Every project located at `{repository root}/{node}/{project}` is assigned a dedicated system user on the host.
|
||||
|
||||
- **Isolation**: If a container is compromised, the attacker only gains the privileges of that specific low-privileged user.
|
||||
- **Filesystem safety**: Projects can only read/write to their own directories within the [Tiers](/root/glossary#tier).
|
||||
|
||||
## Docker socket protection {#socket-protection}
|
||||
|
||||
Direct access to `/var/run/docker.sock` is equivalent to root access on the host. As a best practice, it is highly recommended to protect the Docker socket. One way to achieve this is by utilizing a **Socket Proxy** (e.g., based on `tecnativa/docker-socket-proxy`) to filter and restrict access to the Docker API.
|
||||
|
||||
```yaml filename="docker-socket-proxy-example.yml"
|
||||
services:
|
||||
socket-proxy:
|
||||
image: tecnativa/docker-socket-proxy:0
|
||||
container_name: root-socket-proxy
|
||||
user: "0:0"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
CONTAINERS: 1
|
||||
SERVICES: 1
|
||||
TASKS: 1
|
||||
networks:
|
||||
socket: {}
|
||||
restart: unless-stopped
|
||||
|
||||
traefik:
|
||||
# ...
|
||||
depends_on:
|
||||
- socket-proxy
|
||||
command:
|
||||
# Use the proxy instead of the local socket
|
||||
- "--providers.docker.endpoint=tcp://root-socket-proxy:2375"
|
||||
networks:
|
||||
socket: {}
|
||||
```
|
||||
|
||||
## Security Monitoring (CrowdSec) {#crowdsec}
|
||||
|
||||
The framework integrates **CrowdSec** for behavioral detection and automated response.
|
||||
|
||||
- **Intrusion detection**: CrowdSec monitors logs from [Traefik](https://traefik.io/) and SSH for suspicious patterns (e.g., brute-force attacks, port scanning).
|
||||
- **Automated blocking**: When a threat is detected, the **CrowdSec Firewall Bouncer** automatically updates [UFW](https://help.ubuntu.com/community/UFW) rules to block the malicious IP at the host level.
|
||||
- **Shared threat intelligence**: CrowdSec shares anonymized threat data with its community, providing preemptive protection against known bad actors.
|
||||
|
||||
## Network hardening {#networking}
|
||||
|
||||
Root uses `configure/ufw` to implement a "Default Deny" policy.
|
||||
|
||||
- **Internal routing**: Services communicate over a dedicated `root-proxy` bridge network, which is often configured as `internal: true`.
|
||||
- **Enclave isolation**: Outgoing traffic can be routed through a dedicated egress node for granular control.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Tiered storage](/root/concepts/tiered-storage)
|
||||
- [Guide: Action development](/root/guides/action-development)
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: Tiered storage model
|
||||
description: Standardized storage taxonomy for service data.
|
||||
weight: 15
|
||||
---
|
||||
|
||||
The framework enforces a standardized storage taxonomy. This ensures that every service has a predictable location for its data, which simplifies backup strategies and hardware allocation.
|
||||
|
||||
## The storage taxonomy {#taxonomy}
|
||||
|
||||
Data is divided into three logical [Tiers](/root/glossary#tier) based on its performance requirements, lifecycle, and importance.
|
||||
|
||||
### Tier 1: Configuration & Cache
|
||||
|
||||
**Path:** `${ROOT_NODE_DIR}/@tier1`
|
||||
|
||||
- **Contents**: Config files, small SQLite databases, SSL certificates, application cache.
|
||||
- **Characteristics**: Extremely high value, low to medium volume, requires low latency.
|
||||
- **Hardware**: High-speed SSD storage, typically shared with the Operating System.
|
||||
|
||||
### Tier 2: Application Data
|
||||
|
||||
**Path:** `${ROOT_NODE_DIR}/@tier2`
|
||||
|
||||
- **Contents**: User uploads, large databases, persistent application state.
|
||||
- **Characteristics**: High value, medium to high volume, requires high reliability.
|
||||
- **Hardware**: Reliable HDD clusters, RAID arrays, or dedicated storage compute.
|
||||
|
||||
### Tier 3: Transients & Bulk Media
|
||||
|
||||
**Path:** `${ROOT_NODE_DIR}/@tier3`
|
||||
|
||||
- **Contents**: Logs, temporary files, bulk media (movies, music), and other non-critical large data.
|
||||
- **Characteristics**: Low value, very high volume.
|
||||
- **Hardware**: High-capacity HDDs optimized for continuous sequential write/read (e.g., WD Purple). Note that Tier 3 is **too slow for cache**.
|
||||
|
||||
## Backup strategy {#backup}
|
||||
|
||||
The framework does not enforce a specific backup strategy or provide a default implementation, allowing for complete node-specific flexibility. Operators are expected to implement their own backup logic.
|
||||
|
||||
### Example: Restic backup
|
||||
|
||||
A common best practice is to use **Restic** for backups. For example, you might create an operator compose file to run Restic:
|
||||
|
||||
```yaml filename="operator-compose.yml"
|
||||
services:
|
||||
restic:
|
||||
image: restic/restic:0.18.1
|
||||
container_name: operator-restic
|
||||
volumes:
|
||||
- ./@tier2/{project}/{service}/{data}:/data/@tier2/{project}/{service}/{data}:ro
|
||||
- ./@tier1/{project}/{service}/{data}:/data/@tier1/{project}/{service}/{data}:ro
|
||||
- ./@tier1/.operator/restic/cache:/var/restic-cache
|
||||
environment:
|
||||
RESTIC_REPOSITORY: "s3:example.com/bucket"
|
||||
RESTIC_PASSWORD: ${RESTIC_PASSWORD}
|
||||
# ... AWS/S3 credentials
|
||||
network_mode: "host"
|
||||
entrypoint: [ "restic" ]
|
||||
user: "0:0"
|
||||
```
|
||||
|
||||
### Implementing the strategy
|
||||
|
||||
Operators can implement this strategy by defining a local action in `.operator/local/scripts/actions/sync/tiers`. The `root` dispatcher can then execute this script to trigger the backup, allowing for granular control over how data is persisted.
|
||||
|
||||
## Justification {#justification}
|
||||
|
||||
1. **Hardware optimization**: By mapping tiers to specific hardware (SSDs for Tier 1, RAID for Tier 2, Purples for Tier 3), we optimize for both performance and cost.
|
||||
2. **Selective backup**: Separating transient logs and bulk media (Tier 3) from critical configs (Tier 1) prevents wasting expensive backup storage.
|
||||
3. **Architectural clarity**: Any operator can inspect a [Node](/root/glossary#node) directory and immediately understand the storage requirements and priority of its contents.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Infrastructure architecture](/root/concepts/architecture)
|
||||
- [Reference: ROOT_ API](/root/reference/root-api)
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Glossary
|
||||
description: Terminology used within the Root infrastructure framework.
|
||||
weight: 100
|
||||
---
|
||||
|
||||
Terminology used within the Root infrastructure framework.
|
||||
|
||||
## Action {#action}
|
||||
|
||||
**Action** - An atomic automation task located in `scripts/actions/`. Actions follow the "Stupid Action" philosophy, remaining declarative and relying on the `root` dispatcher for environment setup.
|
||||
|
||||
## Commons {#commons}
|
||||
|
||||
**Commons** - The shared repository containing generic logic, namespaced libraries, and standardized automation actions used across all nodes. Referenced at [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons).
|
||||
|
||||
## Dispatcher {#dispatcher}
|
||||
|
||||
**Dispatcher** - The `root` script that serves as the entrypoint. It resolves paths, warms the environment, and executes the requested [Action](/root/glossary#action).
|
||||
|
||||
## Node {#node}
|
||||
|
||||
**Node** - A physical or virtual host managed by the framework. Each node has a dedicated directory in the configuration repository containing its services and local settings.
|
||||
|
||||
## Root {#root}
|
||||
|
||||
**Root** - Both the name of the command-line utility and the top-level orchestration service within a [Node](/root/glossary#node) (typically handling Traefik and Adguard).
|
||||
|
||||
## Tier {#tier}
|
||||
|
||||
**Tier** - A standardized storage classification for service data.
|
||||
- **Tier 1**: Configuration and critical state.
|
||||
- **Tier 2**: Application data and databases.
|
||||
- **Tier 3**: Logs, caches, and bulk media.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Infrastructure architecture](/root/concepts/architecture)
|
||||
- [Concept: Tiered storage model](/root/concepts/tiered-storage)
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: Guides
|
||||
description: Step-by-step guides for installation and action development.
|
||||
weight: 20
|
||||
---
|
||||
|
||||
Step-by-step guides for installing and developing within the ermnvldmr.com root deployment commons framework.
|
||||
|
||||
{{% cards %}}
|
||||
{{< card link="first-node" title="Setting Up Your First Node" icon="map" subtitle="Practical guide to creating your own config repository." >}}
|
||||
{{< card link="installation" title="Installation" icon="arrow-down" subtitle="Initial setup of the framework on a new host." >}}
|
||||
{{< card link="action-development" title="Action development" icon="code" subtitle="Creating and managing atomic automation tasks." >}}
|
||||
{{% /cards %}}
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user