Compare commits

..
Author SHA1 Message Date
deytenit 1b3d7cecb2 ci: Add release rebase workflow (#21) 2026-07-12 11:38:02 +03:00
deytenit c41450e884 fix: fix www docker build (#20) 2026-07-12 02:38:00 +03:00
deytenit 91f5245823 fix, feat, doc: massive build system refactorion, bug fixes and apex documentation (#19) 2026-07-12 01:57:40 +03:00
dependabot[bot] e8b42c60c2 build(deps): bump fast-uri from 3.1.1 to 3.1.2 in the npm_and_yarn group across 0 directory (#17)
Updates `fast-uri` from 3.1.1 to 3.1.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/fastify/fast-uri/releases">fast-uri's
releases</a>.</em></p>
<blockquote>
<h2>v3.1.2</h2>
<h2>⚠️ Security Release</h2>
<ul>
<li>Fix for <a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-v39h-62p7-jpjc">https://github.com/fastify/fast-uri/security/advisories/GHSA-v39h-62p7-jpjc</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Handle malformed fragment decoding as a parse error by <a
href="https://github.com/mcollina"><code>@​mcollina</code></a> in <a
href="https://redirect.github.com/fastify/fast-uri/pull/171">fastify/fast-uri#171</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/fastify/fast-uri/compare/v3.1.1...v3.1.2">https://github.com/fastify/fast-uri/compare/v3.1.1...v3.1.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/fastify/fast-uri/commit/919dd8ea7689fcc220d0d9b71307f5095e723ef9"><code>919dd8e</code></a>
Bumped v3.1.2</li>
<li><a
href="https://github.com/fastify/fast-uri/commit/c65ba573714af6b8e19e481d9444c27bc4355d07"><code>c65ba57</code></a>
fixup: linting</li>
<li><a
href="https://github.com/fastify/fast-uri/commit/6c86c17c3d76fb93aa3700ec6c0fa00faeb97293"><code>6c86c17</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/fastify/fast-uri/commit/a95158ad308df4d92bbde4eba699ce5165e9f796"><code>a95158a</code></a>
Handle malformed fragment decoding without throwing (<a
href="https://redirect.github.com/fastify/fast-uri/issues/171">#171</a>)</li>
<li>See full diff in <a
href="https://github.com/fastify/fast-uri/compare/v3.1.1...v3.1.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=fast-uri&package-manager=npm_and_yarn&previous-version=3.1.1&new-version=3.1.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/deytenit/ermnvldmr.com/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 23:37:34 +03:00
dependabot[bot] f858cf1989 build(deps-dev): bump postcss from 8.5.6 to 8.5.10 in the npm_and_yarn group across 1 directory (#16)
Bumps the npm_and_yarn group with 1 update in the / directory:
[postcss](https://github.com/postcss/postcss).

Updates `postcss` from 8.5.6 to 8.5.10
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/releases">postcss's
releases</a>.</em></p>
<blockquote>
<h2>8.5.10</h2>
<ul>
<li>Fixed XSS via unescaped <code>&lt;/style&gt;</code> in non-bundler
cases (by <a
href="https://github.com/TharVid"><code>@​TharVid</code></a>).</li>
</ul>
<h2>8.5.9</h2>
<ul>
<li>Speed up source map encoding paring in case of the error.</li>
</ul>
<h2>8.5.8</h2>
<ul>
<li>Fixed <code>Processor#version</code>.</li>
</ul>
<h2>8.5.7</h2>
<ul>
<li>Improved source map annotation cleaning performance (by CodeAnt
AI).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/blob/main/CHANGELOG.md">postcss's
changelog</a>.</em></p>
<blockquote>
<h2>8.5.10</h2>
<ul>
<li>Fixed XSS via unescaped <code>&lt;/style&gt;</code> in non-bundler
cases (by <a
href="https://github.com/TharVid"><code>@​TharVid</code></a>).</li>
</ul>
<h2>8.5.9</h2>
<ul>
<li>Speed up source map encoding paring in case of the error.</li>
</ul>
<h2>8.5.8</h2>
<ul>
<li>Fixed <code>Processor#version</code>.</li>
</ul>
<h2>8.5.7</h2>
<ul>
<li>Improved source map annotation cleaning performance (by CodeAnt
AI).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/postcss/postcss/commit/33b9790263dc1562a46ce45d9532bd63e95b7986"><code>33b9790</code></a>
Release 8.5.10 version</li>
<li><a
href="https://github.com/postcss/postcss/commit/536c79e4b01e58a3a56b09c3c0cf2323f4b9a28b"><code>536c79e</code></a>
Escape &lt;/style&gt; in CSS output (<a
href="https://redirect.github.com/postcss/postcss/issues/2074">#2074</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/afa96b2a139ce625c4d27973313479c7c85f39d4"><code>afa96b2</code></a>
Update dependencies (<a
href="https://redirect.github.com/postcss/postcss/issues/2073">#2073</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/effe88bb87cabdc1876e02adbdd30f392f19f40d"><code>effe88b</code></a>
Typo (<a
href="https://redirect.github.com/postcss/postcss/issues/2072">#2072</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/3ee79a2c4a11e41d52db50b444eebe38299495ad"><code>3ee79a2</code></a>
Thread model (<a
href="https://redirect.github.com/postcss/postcss/issues/2071">#2071</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/2e0683daca4dc2919211b03774f6b2d137136c01"><code>2e0683d</code></a>
Create incident response docs (<a
href="https://redirect.github.com/postcss/postcss/issues/2070">#2070</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/fe88ac29c06b7b218be32994cdc6ca1525bdf2c9"><code>fe88ac2</code></a>
Release 8.5.9 version</li>
<li><a
href="https://github.com/postcss/postcss/commit/c551632496b87ab3f1965bfda5dc386b6c71963e"><code>c551632</code></a>
Avoid RegExp when we can use simple JS</li>
<li><a
href="https://github.com/postcss/postcss/commit/89a6b744060eb8dee743351c785a9fbe37d4525a"><code>89a6b74</code></a>
Move SECURITY.txt for docs folder to keep GitHub page cleaner</li>
<li><a
href="https://github.com/postcss/postcss/commit/6ceb8a46af9f9de821faee98f861bdf84617347b"><code>6ceb8a4</code></a>
Create SECURITY.md</li>
<li>Additional commits viewable in <a
href="https://github.com/postcss/postcss/compare/8.5.6...8.5.10">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=postcss&package-manager=npm_and_yarn&previous-version=8.5.6&new-version=8.5.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/deytenit/ermnvldmr.com/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 14:46:23 +03:00
deytenitandcopilot-swe-agent[bot] ef6ca40c72 feat (www): Add Wanted (wishlist) page (#15)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-04 18:58:46 +03:00
168 changed files with 9341 additions and 1739 deletions

No files matched your search

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