Compare commits

...
Author SHA1 Message Date
deytenit 90bf68c4c8 fix(nginx): add explicit root and fix regex location priority for sta… (#8)
…tic assets

Regex locations (~* .js|css|woff2 etc) have higher priority than prefix
locations (/en/, /ru/) in nginx. Without an explicit root in the server
block, the caching location had no root to serve files from, causing
404s for all /en/static/... and /ru/static/... assets.

- Add root /usr/share/nginx/html to server block so all locations
inherit it
- Remove alias from /en/ and /ru/ locations (redundant with explicit
root)
- Reorder: caching regex locations before locale prefix locations
(documents intent)

Made-with: Cursor
2026-03-16 01:33:29 +03:00
deytenit cd40139eea fix(www): resolve SSG build issues with font preloads, dark theme and… (#7)
… RU locale

- Fix depth-dependent duplicate font preload prefixes by using
locale-absolute fontBase path and publicPath:false on html.tags to
prevent Rsbuild from re-applying assetPrefix (path.posix.join behavior
with MPA nested entries)
- Fix .dark CSS rule stripped by critters: set reduceInlineStyles:false
so critters only processes external stylesheets and leaves inline theme
init styles (THEME_INIT_STYLES) intact
- Fix NormalModuleReplacementPlugin lost in mergeConfig: refactored
locale MDX replacement from tools.rspack function into a proper Rsbuild
plugin so mergeConfig concatenates plugin arrays correctly (enables RU
locale articles)
- Extend Critters type declaration to include reduceInlineStyles option

Made-with: Cursor
2026-03-16 01:27:42 +03:00
deytenit f5e901c131 Add i18n build step to releases workflows (#6)
Build @ermnvldmr/i18n and include its dist in the config-artifacts
bundle for services-www-releases-required and
services-www-releases-dispatch-release workflows, matching the fix
already applied to pr/next workflows.

Made-with: Cursor
2026-03-16 00:12:02 +03:00
deytenit b84ef775b8 Various build shenenigans (#5)
This PR extends the build pipeline of the www service to support
locale-prefixed static builds (EN/RU) with build-time SSG output
injection, while also refactoring UI typography to a tokenized Tailwind
v4 @theme-driven approach.

Changes:

- Add build-time SSG plugin to @ermnvldmr/rsbuild-config and wire
multi-locale builds (/en/, /ru/) into services/www.
- Introduce new @ermnvldmr/i18n package (locale constants, path helpers,
keysets, pluralization) and apply it to www navigation/text.
- Refactor ui typography to a shared getTypographyClassNames utility +
Tailwind token definitions, with stl/cn updated for correct
Tailwind-merge behavior.
2026-03-16 00:00:20 +03:00
dependabot[bot] ac9c455e5a build(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#3)
Bumps the npm_and_yarn group with 1 update in the / directory:
[storybook](https://github.com/storybookjs/storybook/tree/HEAD/code/core).

Updates `storybook` from 8.6.15 to 8.6.17
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/storybookjs/storybook/releases">storybook's
releases</a>.</em></p>
<blockquote>
<h2>v8.6.17</h2>
<h2>8.6.17</h2>
<ul>
<li>Harden websocket connection</li>
</ul>
<h2>v8.6.16</h2>
<h2>8.6.16</h2>
<ul>
<li>No-op release. No changes.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/storybookjs/storybook/blob/v8.6.17/CHANGELOG.md">storybook's
changelog</a>.</em></p>
<blockquote>
<h2>8.6.17</h2>
<ul>
<li>Harden websocket connection</li>
</ul>
<h2>8.6.16</h2>
<ul>
<li>No-op release. No changes.</li>
</ul>
<h2>8.6.14</h2>
<ul>
<li>CLI: Add skip onboarding, recommended/minimal config - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30930">#30930</a>,
thanks <a
href="https://github.com/shilman"><code>@​shilman</code></a>!</li>
<li>Core: Fix using dates in expect statements - <a
href="https://redirect.github.com/storybookjs/storybook/pull/28413">#28413</a>,
thanks <a
href="https://github.com/yann-combarnous"><code>@​yann-combarnous</code></a>!</li>
<li>React Native Web: Fix expo router by setting JSX to automatic - <a
href="https://redirect.github.com/storybookjs/storybook/pull/31484">#31484</a>,
thanks <a
href="https://github.com/dannyhw"><code>@​dannyhw</code></a>!</li>
<li>Test: Make sure that lit arrays are not cloned - <a
href="https://redirect.github.com/storybookjs/storybook/pull/31435">#31435</a>,
thanks <a
href="https://github.com/kasperpeulen"><code>@​kasperpeulen</code></a>!</li>
</ul>
<h2>8.6.13</h2>
<ul>
<li>Controls: Fix boxShadow on empty controls - <a
href="https://redirect.github.com/storybookjs/storybook/pull/27193">#27193</a>,
thanks <a
href="https://github.com/H0onnn"><code>@​H0onnn</code></a>!</li>
<li>React Native Web: Update <code>react-native-web</code> - <a
href="https://redirect.github.com/storybookjs/storybook/pull/31324">#31324</a>,
thanks <a
href="https://github.com/ndelangen"><code>@​ndelangen</code></a>!</li>
</ul>
<h2>8.6.12</h2>
<ul>
<li>CLI: Only install Visual Test Addon if test feature is selected - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30966">#30966</a>,
thanks <a
href="https://github.com/ghengeveld"><code>@​ghengeveld</code></a>!</li>
<li>Core: Fix telemetry error on Storybook UI - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30953">#30953</a>,
thanks <a
href="https://github.com/yannbf"><code>@​yannbf</code></a>!</li>
<li>Ember: Fix <code>ember-template-compiler</code> import for ember 6+
- <a
href="https://redirect.github.com/storybookjs/storybook/pull/30682">#30682</a>,
thanks <a
href="https://github.com/leoeuclids"><code>@​leoeuclids</code></a>!</li>
<li>Next: Update vite-plugin-storybook-nextjs to
2.0.0--canary.33.17a2310.0 - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30997">#30997</a>,
thanks <a
href="https://github.com/kasperpeulen"><code>@​kasperpeulen</code></a>!</li>
<li>Svelte: Exclude <code>node_modules</code> from docgen - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30981">#30981</a>,
thanks <a
href="https://github.com/JReinhold"><code>@​JReinhold</code></a>!</li>
</ul>
<h2>8.6.11</h2>
<ul>
<li>Angular: Fix zone.js support for Angular libraries - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30941">#30941</a>,
thanks <a
href="https://github.com/valentinpalkovic"><code>@​valentinpalkovic</code></a>!</li>
</ul>
<h2>8.6.10</h2>
<ul>
<li>Addon-docs: Fix non-string handling in Stories block - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30913">#30913</a>,
thanks <a
href="https://github.com/JamesIves"><code>@​JamesIves</code></a>!</li>
<li>Nextjs: Fix styled-jsx optimize vite warnings - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30932">#30932</a>,
thanks <a
href="https://github.com/kasperpeulen"><code>@​kasperpeulen</code></a>!</li>
<li>React: Fix actImplementation is not a function - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30929">#30929</a>,
thanks <a
href="https://github.com/kasperpeulen"><code>@​kasperpeulen</code></a>!</li>
</ul>
<h2>8.6.9</h2>
<ul>
<li>Next: Fix react aliases in next vite plugin - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30914">#30914</a>,
thanks <a
href="https://github.com/kasperpeulen"><code>@​kasperpeulen</code></a>!</li>
</ul>
<h2>8.6.8</h2>
<ul>
<li>Angular: Export all files in Angular package.json - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30849">#30849</a>,
thanks <a
href="https://github.com/kasperpeulen"><code>@​kasperpeulen</code></a>!</li>
<li>CLI: Don't add packageManager entry to package.json automatically -
<a
href="https://redirect.github.com/storybookjs/storybook/pull/30855">#30855</a>,
thanks <a
href="https://github.com/kasperpeulen"><code>@​kasperpeulen</code></a>!</li>
<li>React: Allow portable stories to be used in SSR - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30847">#30847</a>,
thanks <a
href="https://github.com/kasperpeulen"><code>@​kasperpeulen</code></a>!</li>
<li>Svelte: Adjust Svelte typings to include Svelte 5 function
components - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30852">#30852</a>,
thanks <a
href="https://github.com/dummdidumm"><code>@​dummdidumm</code></a>!</li>
<li>Telemetry: Make sure that telemetry doesn't fail on init - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30857">#30857</a>,
thanks <a
href="https://github.com/kasperpeulen"><code>@​kasperpeulen</code></a>!</li>
<li>Vite: Update HMR filter to target specific story file types - <a
href="https://redirect.github.com/storybookjs/storybook/pull/30845">#30845</a>,
thanks <a
href="https://github.com/kasperpeulen"><code>@​kasperpeulen</code></a>!</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/storybookjs/storybook/commit/c6e550a58e69723187157165d3610e876518d935"><code>c6e550a</code></a>
Bump version from &quot;8.6.16&quot; to &quot;8.6.17&quot; [skip
ci]</li>
<li><a
href="https://github.com/storybookjs/storybook/commit/9cf9d89dbd357aeb3f872cc35ae301bc1c0fc173"><code>9cf9d89</code></a>
Core: Require token for websocket connections</li>
<li><a
href="https://github.com/storybookjs/storybook/commit/7e5151586ba2475d40e19c592c2f10ce81965ae8"><code>7e51515</code></a>
Bump version from &quot;8.6.15&quot; to &quot;8.6.16&quot; [skip
ci]</li>
<li>See full diff in <a
href="https://github.com/storybookjs/storybook/commits/v8.6.17/code/core">compare
view</a></li>
</ul>
</details>
<br />

Updates `@isaacs/brace-expansion` from 5.0.0 to 5.0.1


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-03-08 02:30:20 +03:00
deytenit ea35a3d9f5 TRIVIAL: Add xray-core-subnet-mimicry article (#4) 2026-03-08 02:29:09 +03:00
deytenit 49b545f1b4 E1: Refactor layouts and fix styles for visible elements in www 2026-03-07 21:02:22 +03:00
deytenit f3a3a52aa8 feat: Refactor layouts in www 2026-03-07 20:59:03 +03:00
deytenit 6aa3a03319 fix: Fix PR issues 2026-03-07 10:51:54 +03:00
deytenit d2903a3715 feat: Update styles for Article 2026-03-06 23:27:01 +03:00
deytenit 7b574a1a66 ci: Add workflows for prs 2026-03-06 23:14:13 +03:00
deytenit 56d0d65971 feat: Add codehighlight for the Code' 2026-03-06 23:06:30 +03:00
deytenit 6784a28701 feat: Variate Separator thinkness 2026-03-06 22:41:44 +03:00
deytenit a95723c4f1 feat: Update Code styles 2026-03-06 22:16:34 +03:00
deytenit 4e36e800d4 feat: Add more thinned variants to Separator 2026-03-06 21:19:41 +03:00
deytenit 8d57d6ba1b feat: Add PageHead component 2026-03-06 09:50:38 +03:00
deytenit 30fd52b8e9 chore: Bump hextra submodule 2026-02-26 22:25:25 +03:00
deytenit e9f27f2437 ci: Fix docs build via author 2026-02-26 00:37:31 +03:00
deytenit 52ada4eba6 docs: Add missing docs for initial host setup 2026-02-26 00:32:15 +03:00
deytenit 9c8e57a7b1 feat: Add local fontfaces 2026-02-15 19:26:23 +03:00
deytenit a571b343c8 fix: Fix several lighthouse issues 2026-02-15 19:14:12 +03:00
deytenit 90e8fad496 chore: Fix lint errors in UI 2026-02-15 18:57:01 +03:00
deytenit 0784e0b305 feat: Introduce SSG 2026-02-15 18:48:51 +03:00
482 changed files with 10428 additions and 19288 deletions

No files matched your search

+3 -3
View File
@@ -257,9 +257,9 @@ Use for large screenshots, detailed logs, or deep-dive explanations that disrupt
Use when the user must choose _one_ path (e.g., OS specific, Language specific).
```markdown
{{< tabs items="JSON,YAML" >}}
{{< tab >}} **JSON** content here... {{< /tab >}}
{{< tab >}} **YAML** content here... {{< /tab >}}
{{< tabs >}}
{{< tab name="JSON" >}} **JSON** content here... {{< /tab >}}
{{< tab name="YAML" >}} **YAML** content here... {{< /tab >}}
{{< /tabs >}}
```
@@ -0,0 +1,172 @@
name: packages/stl - pr/required
on:
pull_request:
branches: [next]
paths:
- 'packages/stl/**'
- '.github/workflows/packages-stl-pr-required.yml'
- 'pnpm-lock.yaml'
concurrency:
group: ci-pr-stl-${{ github.head_ref }}
cancel-in-progress: true
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-stl:
name: Lint - STL
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 STL package
run: pnpm --filter @ermnvldmr/stl lint
typecheck-stl:
name: Typecheck - STL
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 STL package
run: pnpm --filter @ermnvldmr/stl typecheck
test-stl:
name: Test - STL
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: Test STL package
run: pnpm --filter @ermnvldmr/stl test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./packages/stl/coverage/lcov.info
flags: stl
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build-stl:
name: Build - STL
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 STL package
run: pnpm --filter @ermnvldmr/stl build
@@ -6,6 +6,7 @@ on:
paths:
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/packages-ui-next-required.yml'
- 'pnpm-lock.yaml'
@@ -64,6 +65,8 @@ jobs:
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build Storybook config
run: pnpm --filter @ermnvldmr/storybook-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Rsbuild Config
uses: actions/upload-artifact@v4
with:
@@ -76,6 +79,12 @@ jobs:
name: storybook-config-dist
path: packages/storybook-config/dist
retention-days: 1
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
retention-days: 1
lint-ui:
name: Lint - UI
@@ -103,6 +112,11 @@ jobs:
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Lint UI package
run: pnpm --filter @ermnvldmr/ui lint
@@ -132,6 +146,11 @@ jobs:
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Typecheck UI package
run: pnpm --filter @ermnvldmr/ui typecheck
@@ -161,6 +180,11 @@ jobs:
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Test UI package
run: pnpm --filter @ermnvldmr/ui test:coverage
- name: Upload coverage to Codecov
@@ -197,6 +221,11 @@ jobs:
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Build UI Storybook
run: pnpm --filter @ermnvldmr/ui build-storybook
@@ -226,6 +255,11 @@ jobs:
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Install Playwright browsers
run: pnpm --filter @ermnvldmr/ui exec playwright install --with-deps chromium
- name: Build UI Storybook
@@ -266,5 +300,10 @@ jobs:
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Build UI package
run: pnpm --filter @ermnvldmr/ui build
@@ -0,0 +1,318 @@
name: packages/ui - pr/required
on:
pull_request:
branches: [next]
paths:
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/packages-ui-pr-required.yml'
- 'pnpm-lock.yaml'
concurrency:
group: ci-pr-ui-${{ github.head_ref }}
cancel-in-progress: true
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: Build Storybook config
run: pnpm --filter @ermnvldmr/storybook-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Rsbuild Config
uses: actions/upload-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
retention-days: 1
- name: Upload Storybook Config
uses: actions/upload-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
retention-days: 1
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
retention-days: 1
lint-ui:
name: Lint - UI
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 Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Lint UI package
run: pnpm --filter @ermnvldmr/ui lint
typecheck-ui:
name: Typecheck - UI
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 Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Typecheck UI package
run: pnpm --filter @ermnvldmr/ui typecheck
test-ui:
name: Test - Unit
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 Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Test UI package
run: pnpm --filter @ermnvldmr/ui test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./packages/ui/coverage/lcov.info
flags: ui
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build-storybook:
name: Build - Storybook
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 Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Build UI Storybook
run: pnpm --filter @ermnvldmr/ui build-storybook
- name: Upload Storybook build
uses: actions/upload-artifact@v4
with:
name: ui-storybook-static-${{ github.run_id }}
path: packages/ui/storybook-static/
retention-days: 1
test-storybook:
name: Test - Storybook
needs: build-storybook
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 Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Install Playwright browsers
run: pnpm --filter @ermnvldmr/ui exec playwright install --with-deps chromium
- name: Download Storybook build
uses: actions/download-artifact@v4
with:
name: ui-storybook-static-${{ github.run_id }}
path: packages/ui/storybook-static/
- name: Test Storybook
run: pnpm --filter @ermnvldmr/ui test:storybook:ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./packages/ui/coverage/storybook/lcov.info
flags: ui-storybook
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build-ui:
name: Build - UI
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 Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Build UI package
run: pnpm --filter @ermnvldmr/ui build
@@ -0,0 +1,50 @@
name: services/docs - pr/required
on:
pull_request:
branches: [next]
paths:
- 'services/docs/**'
- '.github/workflows/services-docs-pr-required.yml'
concurrency:
group: ci-pr-docs-${{ github.head_ref }}
cancel-in-progress: true
jobs:
setup-hugo:
name: Setup - Hugo
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Setup Hugo
uses: peaceiris/actions-hugo@v2
with:
hugo-version: 'latest'
extended: true
build-hugo:
name: Build - Hugo Site
needs: setup-hugo
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Setup Hugo
uses: peaceiris/actions-hugo@v2
with:
hugo-version: 'latest'
extended: true
- name: Build Hugo site
working-directory: services/docs
run: hugo --gc --minify
@@ -7,6 +7,7 @@ on:
- 'services/static/**'
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/services-static-next-required.yml'
- 'pnpm-lock.yaml'
@@ -63,12 +64,20 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build Rsbuild config
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Config Artifacts
uses: actions/upload-artifact@v4
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
retention-days: 1
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
retention-days: 1
lint-static:
name: Lint - Static
@@ -91,6 +100,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Lint static service
run: pnpm --filter @ermnvldmr/static lint
@@ -115,6 +129,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Typecheck static service
run: pnpm --filter @ermnvldmr/static typecheck
@@ -139,6 +158,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Build static service
run: pnpm --filter @ermnvldmr/static build
- name: Upload build artifacts
@@ -0,0 +1,173 @@
name: services/static - pr/required
on:
pull_request:
branches: [next]
paths:
- 'services/static/**'
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/services-static-pr-required.yml'
- 'pnpm-lock.yaml'
concurrency:
group: ci-pr-static-${{ github.head_ref }}
cancel-in-progress: true
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: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Config Artifacts
uses: actions/upload-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
retention-days: 1
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
retention-days: 1
lint-static:
name: Lint - Static
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: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Lint static service
run: pnpm --filter @ermnvldmr/static lint
typecheck-static:
name: Typecheck - Static
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: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Typecheck static service
run: pnpm --filter @ermnvldmr/static typecheck
build-static:
name: Build - Static Site
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: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Build static service
run: pnpm --filter @ermnvldmr/static build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: static-dist-${{ github.run_id }}
path: services/static/dist/
retention-days: 1
@@ -7,6 +7,7 @@ on:
- 'services/www/**'
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/services-www-next-required.yml'
- 'pnpm-lock.yaml'
@@ -77,6 +78,14 @@ jobs:
name: storybook-config-dist
path: packages/storybook-config/dist
retention-days: 1
- name: Build i18n package
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
retention-days: 1
lint-www:
name: Lint - WWW
@@ -99,6 +108,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -128,6 +142,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -157,6 +176,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -192,6 +216,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -228,6 +257,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -271,13 +305,26 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Build WWW service
run: pnpm --filter @ermnvldmr/www build
- name: Build WWW service (all locales)
run: pnpm --filter @ermnvldmr/www build:all
env:
RSDOCTOR: 'true'
- name: Upload Rsdoctor report
uses: actions/upload-artifact@v4
with:
name: rsdoctor-report
path: services/www/rsdoctor/
retention-days: 30
release-docker:
name: Release - Docker Image
@@ -0,0 +1,327 @@
name: services/www - pr/required
on:
pull_request:
branches: [next]
paths:
- 'services/www/**'
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/services-www-pr-required.yml'
- 'pnpm-lock.yaml'
concurrency:
group: ci-pr-www-${{ github.head_ref }}
cancel-in-progress: true
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: Build Storybook config
run: pnpm --filter @ermnvldmr/storybook-config build
- name: Upload Rsbuild Config
uses: actions/upload-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
retention-days: 1
- name: Upload Storybook Config
uses: actions/upload-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
retention-days: 1
- name: Build i18n package
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
retention-days: 1
lint-www:
name: Lint - WWW
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 Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Lint WWW service
run: pnpm --filter @ermnvldmr/www lint
typecheck-www:
name: Typecheck - WWW
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 Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Typecheck WWW service
run: pnpm --filter @ermnvldmr/www typecheck
build-storybook:
name: Build - Storybook
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 Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Build WWW Storybook
run: pnpm --filter @ermnvldmr/www build-storybook
- name: Upload Storybook build
uses: actions/upload-artifact@v4
with:
name: www-storybook-static-${{ github.run_id }}
path: services/www/storybook-static/
retention-days: 1
test-www:
name: Test - Unit
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 Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Test WWW service
run: pnpm --filter @ermnvldmr/www test:coverage
- name: Upload Unit coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./services/www/coverage/lcov.info
flags: www
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
test-storybook:
name: Test - Storybook
needs: build-storybook
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 Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Install Playwright browsers
run: pnpm --filter @ermnvldmr/www exec playwright install --with-deps chromium
- name: Download Storybook build
uses: actions/download-artifact@v4
with:
name: www-storybook-static-${{ github.run_id }}
path: services/www/storybook-static/
- name: Serve Storybook and run tests with coverage
run: pnpm --filter @ermnvldmr/www test:storybook:ci
- name: Upload Storybook coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./services/www/coverage/storybook/lcov.info
flags: www-storybook
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build-www:
name: Build - WWW Service
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 Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Build WWW service (all locales)
run: pnpm --filter @ermnvldmr/www build:all
env:
RSDOCTOR: 'true'
- name: Upload Rsdoctor report
uses: actions/upload-artifact@v4
with:
name: rsdoctor-report-${{ github.run_id }}
path: services/www/rsdoctor/
retention-days: 7
@@ -54,6 +54,8 @@ jobs:
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build Storybook config
run: pnpm --filter @ermnvldmr/storybook-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Config Artifacts
uses: actions/upload-artifact@v4
with:
@@ -61,10 +63,11 @@ jobs:
path: |
packages/rsbuild-config/dist
packages/storybook-config/dist
packages/i18n/dist
retention-days: 1
lint-www:
name: Lint - WWW & Deps
name: Lint - WWW
needs: build-configs
runs-on: ubuntu-latest
steps:
@@ -84,15 +87,11 @@ jobs:
with:
name: config-artifacts
path: packages
- name: Lint STL package
run: pnpm --filter @ermnvldmr/stl lint
- name: Lint UI package
run: pnpm --filter @ermnvldmr/ui lint
- name: Lint WWW service
run: pnpm --filter @ermnvldmr/www lint
typecheck-www:
name: Typecheck - WWW & Deps
name: Typecheck - WWW
needs: build-configs
runs-on: ubuntu-latest
steps:
@@ -112,10 +111,6 @@ jobs:
with:
name: config-artifacts
path: packages
- name: Typecheck STL package
run: pnpm --filter @ermnvldmr/stl typecheck
- name: Typecheck UI package
run: pnpm --filter @ermnvldmr/ui typecheck
- name: Typecheck WWW service
run: pnpm --filter @ermnvldmr/www typecheck
@@ -170,10 +165,6 @@ jobs:
with:
name: config-artifacts
path: packages
- name: Test STL package
run: pnpm --filter @ermnvldmr/stl test:coverage
- name: Test UI package
run: pnpm --filter @ermnvldmr/ui test:coverage
- name: Test WWW service
run: pnpm --filter @ermnvldmr/www test:coverage
@@ -60,6 +60,8 @@ jobs:
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build Storybook config
run: pnpm --filter @ermnvldmr/storybook-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Config Artifacts
uses: actions/upload-artifact@v4
with:
@@ -67,10 +69,11 @@ jobs:
path: |
packages/rsbuild-config/dist
packages/storybook-config/dist
packages/i18n/dist
retention-days: 1
lint-www:
name: Lint - WWW & Deps
name: Lint - WWW
needs: build-configs
runs-on: ubuntu-latest
steps:
@@ -90,15 +93,11 @@ jobs:
with:
name: config-artifacts
path: packages
- name: Lint STL package
run: pnpm --filter @ermnvldmr/stl lint
- name: Lint UI package
run: pnpm --filter @ermnvldmr/ui lint
- name: Lint WWW service
run: pnpm --filter @ermnvldmr/www lint
typecheck-www:
name: Typecheck - WWW & Deps
name: Typecheck - WWW
needs: build-configs
runs-on: ubuntu-latest
steps:
@@ -118,10 +117,6 @@ jobs:
with:
name: config-artifacts
path: packages
- name: Typecheck STL package
run: pnpm --filter @ermnvldmr/stl typecheck
- name: Typecheck UI package
run: pnpm --filter @ermnvldmr/ui typecheck
- name: Typecheck WWW service
run: pnpm --filter @ermnvldmr/www typecheck
@@ -176,10 +171,6 @@ jobs:
with:
name: config-artifacts
path: packages
- name: Test STL package
run: pnpm --filter @ermnvldmr/stl test:coverage
- name: Test UI package
run: pnpm --filter @ermnvldmr/ui test:coverage
- name: Test WWW service
run: pnpm --filter @ermnvldmr/www test:coverage
+1
View File
@@ -30,3 +30,4 @@ playwright-report/
# agents plans and designs
docs/plans/
docs/superpowers/
+3
View File
@@ -0,0 +1,3 @@
[submodule "services/docs/themes/hextra"]
path = services/docs/themes/hextra
url = https://github.com/imfing/hextra
+27 -9
View File
@@ -12,20 +12,38 @@ server {
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
# Clean URLs (remove .html from URI)
location / {
try_files $uri $uri/ $uri.html /index.html;
# Root redirect: detect browser language, fallback to /en/
location = / {
set $locale "en";
if ($http_accept_language ~* "(^|,\s*)ru") {
set $locale "ru";
}
return 302 /$locale/;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# Cache hashed static assets (JS, CSS, fonts) — matched before locale prefix blocks
# root is inherited from server block, so /en/static/js/file.js resolves correctly
location ~* \.(js|css|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache images
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, no-transform";
}
# Error pages
error_page 404 /404.html;
location = /404.html {
internal;
# English locale — SPA fallback for non-asset routes
location /en/ {
try_files $uri $uri/ $uri.html /en/index.html;
}
# Russian locale — SPA fallback for non-asset routes
location /ru/ {
try_files $uri $uri/ $uri.html /ru/index.html;
}
# Error pages
error_page 404 /en/index.html;
}
+4 -2
View File
@@ -16,6 +16,7 @@ COPY packages/rsbuild-config/package.json ./packages/rsbuild-config/
COPY packages/stl/package.json ./packages/stl/
COPY packages/storybook-config/package.json ./packages/storybook-config/
COPY packages/ui/package.json ./packages/ui/
COPY packages/i18n/package.json ./packages/i18n/
COPY services/www/package.json ./services/www/
# Install dependencies
@@ -27,10 +28,11 @@ COPY . .
# Build dependencies
RUN pnpm --filter @ermnvldmr/rsbuild-config build
RUN pnpm --filter @ermnvldmr/stl build
RUN pnpm --filter @ermnvldmr/i18n build
RUN pnpm --filter @ermnvldmr/ui build
# Build the website
RUN pnpm --filter @ermnvldmr/www build
# Build the website (all locales)
RUN pnpm --filter @ermnvldmr/www build:all
# Stage 2: Runtime
FROM nginx:alpine
+12 -6
View File
@@ -2,30 +2,36 @@ codecov:
notify:
wait_for_ci: true
ignore:
- 'packages/eslint-config'
- 'packages/rsbuild-config'
- 'packages/storybook-config'
coverage:
status:
project:
default:
target: auto
target: 80%
threshold: 1%
range: 10%
stl:
target: auto
target: 80%
flags:
- stl
ui:
target: auto
target: 80%
flags:
- ui
ui-storybook:
target: auto
target: 80%
flags:
- ui-storybook
www:
target: auto
target: 80%
flags:
- www
www-storybook:
target: auto
target: 80%
flags:
- www-storybook
@@ -21,8 +21,8 @@ export const jsdocConfig = [
},
],
'jsdoc/require-description': 'error',
'jsdoc/require-param': 'error',
'jsdoc/require-returns': 'error',
'jsdoc/require-param': 'off',
'jsdoc/require-returns': 'off',
'jsdoc/require-example': 'warn',
'jsdoc/check-alignment': 'error',
'jsdoc/check-indentation': 'error',
@@ -44,10 +44,7 @@ export function getTypescriptConfig(rootDir) {
assertionStyle: 'never',
},
],
'@typescript-eslint/explicit-function-return-type': [
'error',
{ allowExpressions: true, allowTypedFunctionExpressions: true },
],
'@typescript-eslint/explicit-function-return-type': 'off',
},
},
// Allow triple-slash references in `*.d.ts` files.
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@ermnvldmr/i18n",
"version": "1.0.0",
"description": "Internationalization utilities for ermnvldmr projects",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./src/index.ts"
},
"./dev": {
"import": "./dist/dev.js",
"types": "./dist/dev.d.ts",
"default": "./src/dev.ts"
},
"./testing": {
"import": "./dist/testing.js",
"types": "./dist/testing.d.ts",
"default": "./src/testing.ts"
}
},
"files": ["dist"],
"scripts": {
"build": "rsbuild build",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src --max-warnings 0",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist coverage"
},
"peerDependencies": {
"react": "^19.1.0"
},
"devDependencies": {
"@ermnvldmr/eslint-config": "workspace:*",
"@ermnvldmr/rsbuild-config": "workspace:*",
"@rsbuild/core": "^1.2.11",
"@types/react": "^19.1.2",
"@vitest/coverage-v8": "^4.0.18",
"react": "^19.1.0",
"typescript": "^5.8.3",
"vitest": "^4.0.18"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { definePackageConfig } from '@ermnvldmr/rsbuild-config/dev';
export default definePackageConfig({
source: {
entry: {
index: './src/index.ts',
dev: './src/dev.ts',
testing: './src/testing.ts',
},
alias: { '@': './src' },
},
output: {
target: 'web',
distPath: { root: 'dist', js: '' },
filename: { js: '[name].js' },
cleanDistPath: true,
},
tools: {
htmlPlugin: false,
rspack: {
output: { library: { type: 'module' } },
experiments: { outputModule: true },
externals: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
externalsType: 'module',
},
},
});
+73
View File
@@ -0,0 +1,73 @@
import { DEFAULT_LOCALE, isLocale, type Locale } from './lib/locale.js';
import type { RsbuildConfig, RsbuildPlugin } from '@rsbuild/core';
/**
* Returns a partial Rsbuild config that enables locale-aware builds.
*
* The locale MDX replacement is provided as an Rsbuild plugin (rather than a
* `tools.rspack` function) so that `mergeConfig` can safely concatenate it
* with the service's own `plugins` array. A `tools.rspack` function would be
* silently dropped by `mergeConfig` when the service config also defines
* `tools.rspack` as a plain object.
*
* Designed to be merged with defineServiceConfig() in each service's rsbuild.config.ts:
*
* @example
* ```ts
* import { localeRsbuildConfig } from '@ermnvldmr/i18n/dev';
* import { defineServiceConfig, mergeConfig } from '@ermnvldmr/rsbuild-config/dev';
*
* export default defineServiceConfig(mergeConfig(localeRsbuildConfig(), { ... }));
* ```
*/
export function localeRsbuildConfig(): RsbuildConfig {
const rawLocale = process.env.LOCALE ?? '';
const locale: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
const localePlugins: RsbuildPlugin[] = [];
if (locale !== DEFAULT_LOCALE) {
localePlugins.push({
name: 'ermnvldmr:locale-mdx-replacement',
setup(api) {
// Resolve .en.mdx imports to the locale-specific file when LOCALE != 'en'.
// The canonical import is always `.en.mdx`; other locales are resolved here.
api.modifyRspackConfig((config, { rspack }) => {
config.plugins = [
...(config.plugins ?? []),
new rspack.NormalModuleReplacementPlugin(
/\.en\.mdx$/,
(resource: { request: string }) => {
resource.request = resource.request.replace(/\.en\.mdx$/, `.${locale}.mdx`);
}
),
];
return config;
});
},
});
}
return {
source: {
define: {
'process.env.LOCALE': JSON.stringify(locale),
},
},
server: {
// Dev server serves pages under the locale base so localePath() links
// resolve correctly in dev without any NODE_ENV branching.
base: `/${locale}`,
},
output: {
distPath: {
root: `dist/${locale}`,
},
assetPrefix: `/${locale}/`,
},
plugins: localePlugins,
};
}
export { LOCALES, DEFAULT_LOCALE, type Locale } from './lib/locale.js';
+4
View File
@@ -0,0 +1,4 @@
export * from './lib/locale.js';
export * from './lib/pluralize.js';
export * from './lib/keyset.js';
export * from './lib/path.js';
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { createKeyset } from './keyset.js';
const { t, tRaw } = createKeyset({
greeting: { en: 'Hello', ru: 'Привет' },
'count {value}': { en: 'count {value}', ru: 'счёт {value}' },
items: {
en: { none: 'no items', one: 'one item', some: '{count} items', many: '{count} items' },
ru: { none: 'нет элементов', one: 'один элемент', some: '{count} элемента', many: '{count} элементов' },
},
});
describe('t', () => {
it('returns plain translation', () => expect(t('greeting')).toBe('Hello'));
it('interpolates {value}', () => expect(t('count {value}', { value: '42' })).toBe('count 42'));
it('pluralizes none', () => expect(t('items', { count: 0 })).toBe('no items'));
it('pluralizes one', () => expect(t('items', { count: 1 })).toBe('one item'));
it('pluralizes many with interpolation', () =>
expect(t('items', { count: 5 })).toBe('5 items'));
});
describe('tRaw', () => {
it('returns string node for plain key', () => expect(tRaw('greeting')).toBe('Hello'));
it('interpolates ReactNode', () => {
const node = tRaw('count {value}', { value: React.createElement('strong', null, '42') });
expect(Array.isArray(node)).toBe(true);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { LOCALE } from './locale.js';
import { pluralize, type PluralForms } from './pluralize.js';
import type { ReactNode } from 'react';
/** A translation entry with a plain string per locale. */
interface PlainTranslation { en: string; ru: string }
/** A translation entry with plural forms per locale. */
interface PluralTranslation { en: PluralForms; ru: PluralForms }
/** Union of all supported translation shapes. */
type Translation = PlainTranslation | PluralTranslation;
/** A map of translation keys to their translation entries. */
type Keyset = Record<string, Translation>;
/**
* Returns `true` when the translation entry uses plural forms.
*
* @param t - The translation entry to inspect.
* @returns Type predicate narrowing `t` to {@link PluralTranslation}.
* @example
* isPluralTranslation({ en: 'hello', ru: 'привет' }) // → false
*/
function isPluralTranslation(t: Translation): t is PluralTranslation {
return typeof t.en === 'object';
}
/**
* Replaces `{key}` placeholders in `template` with values from `params`.
*
* @param template - String containing `{key}` placeholders.
* @param params - Map of placeholder names to string/number values.
* @returns The interpolated string.
* @example
* interpolate('Hello {name}!', { name: 'World' }) // → 'Hello World!'
*/
function interpolate(template: string, params: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) =>
key in params ? String(params[key]) : `{${key}}`
);
}
/**
* Splits `template` on `{key}` placeholders and substitutes ReactNode values.
*
* @param template - String containing `{key}` placeholders.
* @param params - Map of placeholder names to ReactNode values.
* @returns A single string when all parts are strings, otherwise a ReactNode array.
* @example
* interpolateRaw('Click {link}', { link: <a href="/">here</a> })
*/
function interpolateRaw(template: string, params: Record<string, ReactNode>): ReactNode {
const parts = template.split(/(\{[^}]+\})/g);
const result = parts.map((part) => {
const match = /^\{(\w+)\}$/.exec(part);
if (match) {
const key = match[1];
return key in params ? params[key] : part;
}
return part;
});
if (result.every((p): p is string => typeof p === 'string')) {
return result.join('');
}
return result;
}
/**
* Creates a typed `t` and `tRaw` pair bound to `keyset`.
*
* @param keyset - An object mapping translation keys to their locale entries.
* @returns `{ t, tRaw }` translation helpers.
* @example
* const { t } = createKeyset({ greeting: { en: 'Hello', ru: 'Привет' } });
* t('greeting') // → 'Hello' (in EN build)
*/
export function createKeyset<const K extends Keyset>(keyset: K) {
/** String union of all keys defined in `keyset`. */
type Keys = keyof K & string;
/**
* Returns the translated string for `key`, with optional interpolation and pluralisation.
*
* @param key - A key defined in the keyset.
* @param params - Optional interpolation/pluralisation params.
* @returns The translated string for the current build locale.
* @example
* t('greeting') // → 'Hello'
* t('items {count}', { count: 3 }) // → '3 items'
*/
function t(key: Keys, params?: Record<string, string | number>): string {
const entry = keyset[key];
if (isPluralTranslation(entry)) {
const forms = entry[LOCALE];
const count = typeof params?.count === 'number' ? params.count : 0;
const template = pluralize(count, forms);
return params ? interpolate(template, params) : template;
}
const template = entry[LOCALE];
return params ? interpolate(template, params) : template;
}
/**
* Returns the translated ReactNode for `key`, allowing ReactNode interpolation values.
*
* @param key - A key defined in the keyset.
* @param params - Optional interpolation params; values may be any ReactNode.
* @returns The translated string or ReactNode array for the current build locale.
* @example
* tRaw('click {link}', { link: <a href="/">here</a> })
*/
function tRaw(key: Keys, params?: Record<string, ReactNode>): ReactNode {
const entry = keyset[key];
if (isPluralTranslation(entry)) {
const forms = entry[LOCALE];
const countParam = params?.count;
const count = typeof countParam === 'number' ? countParam : 0;
const template = pluralize(count, forms);
return params ? interpolateRaw(template, params) : template;
}
const template = entry[LOCALE];
return params ? interpolateRaw(template, params) : template;
}
return { t, tRaw };
}
+50
View File
@@ -0,0 +1,50 @@
/** The supported locale identifiers. */
export type Locale = 'en' | 'ru';
export const LOCALES: readonly Locale[] = ['en', 'ru'] as const;
export const DEFAULT_LOCALE: Locale = 'en';
// Widened once so inclusion checks need no type assertions.
const LOCALES_STRINGS: readonly string[] = LOCALES;
/**
* Returns `true` when `value` is a known {@link Locale} identifier.
*
* @param value - The string to test.
* @returns Type predicate narrowing `value` to `Locale`.
* @example
* isLocale('en') // → true
* isLocale('fr') // → false
*/
export function isLocale(value: string): value is Locale {
return LOCALES_STRINGS.includes(value);
}
/**
* The active locale for the current build.
* Injected at compile time via source.define → process.env.LOCALE.
* Falls back to DEFAULT_LOCALE in non-locale-aware builds (tests, Storybook).
*/
const rawLocale = process.env.LOCALE ?? '';
export const LOCALE: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
/**
* The other (non-active) locale for the current build.
* Constant-folded at build time.
*/
export const OTHER_LOCALE: Locale = LOCALES.find((l) => l !== LOCALE) ?? DEFAULT_LOCALE;
/**
* Human-readable label for switching to the other locale,
* expressed in the CURRENT locale's language.
* - EN build → "In Russian"
* - RU build → "На Английском"
*/
const SWITCH_LABELS: Record<Locale, Partial<Record<Locale, string>>> = {
en: { ru: 'In Russian' },
ru: { en: 'На Английском' },
};
export const OTHER_LOCALE_LABEL: string =
SWITCH_LABELS[LOCALE][OTHER_LOCALE] ?? OTHER_LOCALE.toUpperCase();
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { localePath, localeHref } from './path.js';
describe('localePath', () => {
it('prepends LOCALE to absolute path', () =>
expect(localePath('/articles')).toBe('/en/articles'));
it('handles root path', () =>
expect(localePath('/')).toBe('/en/'));
it('handles nested path', () =>
expect(localePath('/articles/2026/test')).toBe('/en/articles/2026/test'));
});
describe('localeHref', () => {
it('replaces locale prefix in URL', () =>
expect(localeHref('/en/articles', 'ru')).toBe('/ru/articles'));
it('replaces locale at root', () =>
expect(localeHref('/en/', 'ru')).toBe('/ru/'));
it('falls back to path if no prefix found', () =>
expect(localeHref('/articles', 'ru')).toBe('/ru/articles'));
});
+57
View File
@@ -0,0 +1,57 @@
import { LOCALE, LOCALES, type Locale } from './locale.js';
/**
* Resolves any href value to its locale-aware form.
*
* Only absolute internal paths (starting with `/`) are rewritten; everything else
* (external URLs, mailto/tel, relative paths, hash anchors) passes through unchanged.
* The function is idempotent: paths already carrying any known locale prefix are returned as-is.
*
* Works identically in dev and production: the dev server is configured with
* `server.base = /${LOCALE}` via `localeRsbuildConfig`.
*
* @param href - The href value to resolve.
* @returns The locale-prefixed href, or the original value when no rewrite is needed.
* @example
* // LOCALE = 'en'
* localePath('/articles') // → '/en/articles'
* localePath('/') // → '/en/'
* localePath('/en/articles') // → '/en/articles' (idempotent)
* localePath('https://github.com') // → 'https://github.com' (external)
* localePath('mailto:a@b.com') // → 'mailto:a@b.com'
* localePath('#section') // → '#section'
*/
export function localePath(href: string): string;
export function localePath(href: null): null;
export function localePath(href: undefined): undefined;
export function localePath(href: string | undefined): string | undefined;
export function localePath(href: string | null): string | null;
export function localePath(href: string | null | undefined): string | null | undefined;
export function localePath(href: string | null | undefined): string | null | undefined {
if (href == null) return href;
if (!href.startsWith('/')) return href;
if (LOCALES.some((l) => href === `/${l}` || href === `/${l}/` || href.startsWith(`/${l}/`))) {
return href;
}
if (href === '/') return `/${LOCALE}/`;
return `/${LOCALE}${href}`;
}
/**
* Builds a URL for a specific locale given the current locale-prefixed URL.
*
* @param currentPath - The current page path, e.g. `/en/articles`.
* @param locale - The target locale.
* @returns The equivalent path for `locale`, e.g. `/ru/articles`.
* @example
* localeHref('/en/articles', 'ru') // → '/ru/articles'
* localeHref('/en/', 'ru') // → '/ru/'
*/
export function localeHref(currentPath: string, locale: Locale): string {
const localePrefix = new RegExp(`^\\/(${LOCALES.join('|')})(.*)`);
const match = localePrefix.exec(currentPath);
if (match) {
return `/${locale}${match[2] || '/'}`;
}
return `/${locale}${currentPath}`;
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { pluralize } from './pluralize.js';
const forms = { none: 'none', one: 'one', some: 'some', many: 'many' };
describe('pluralize', () => {
it('returns none for 0', () => expect(pluralize(0, forms)).toBe('none'));
it('returns one for 1', () => expect(pluralize(1, forms)).toBe('one'));
it('returns some for 2', () => expect(pluralize(2, forms)).toBe('some'));
it('returns some for 4', () => expect(pluralize(4, forms)).toBe('some'));
it('returns many for 5', () => expect(pluralize(5, forms)).toBe('many'));
it('returns many for 11', () => expect(pluralize(11, forms)).toBe('many'));
it('returns many for 12', () => expect(pluralize(12, forms)).toBe('many'));
it('returns many for 100', () => expect(pluralize(100, forms)).toBe('many'));
it('returns one for 21', () => expect(pluralize(21, forms)).toBe('one'));
it('returns some for 22', () => expect(pluralize(22, forms)).toBe('some'));
it('returns many for 111', () => expect(pluralize(111, forms)).toBe('many'));
});
+30
View File
@@ -0,0 +1,30 @@
/** The four plural categories used across EN and RU grammar rules. */
export interface PluralForms {
none: string;
one: string;
some: string;
many: string;
}
/**
* Selects the correct plural form for the given count.
* Covers both English (none/one/many) and Russian (none/one/some/many) rules.
*
* @param count - The numeric value to pluralise.
* @param forms - The set of plural forms to select from.
* @returns The appropriate plural form string.
* @example
* const forms = { none: 'no items', one: 'one item', some: '{n} items', many: '{n} items' };
* pluralize(0, forms) // → 'no items'
* pluralize(1, forms) // → 'one item'
* pluralize(3, forms) // → '{n} items' (some)
* pluralize(21, forms) // → 'one item' (Russian rule: 21 ends in 1)
*/
export function pluralize(count: number, forms: PluralForms): string {
if (count === 0) return forms.none;
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return forms.one;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return forms.some;
return forms.many;
}
+2
View File
@@ -0,0 +1,2 @@
export { LOCALE, DEFAULT_LOCALE, isLocale } from './lib/locale.js';
export { localePath, localeHref } from './lib/path.js';
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}
+8
View File
@@ -12,10 +12,18 @@
"dependencies": {
"@rsbuild/plugin-react": "^1.1.1",
"@rsbuild/plugin-type-check": "^1.1.1",
"critters": "^0.0.25",
"fast-glob": "^3.3.3",
"jsdom": "^28.0.0",
"react": "^19.1.0",
"rsbuild-plugin-dts": "^0.19.5"
},
"devDependencies": {
"@rsbuild/core": "^1.7.3",
"@types/jsdom": "^27.0.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"react-dom": "^19.1.0",
"typescript": "^5.8.2",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.18"
@@ -0,0 +1,31 @@
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,2 +1,3 @@
export * from './rsbuild.js';
export * from './vitest.js';
export * from './entries.js';
@@ -3,6 +3,9 @@ import { pluginReact } from '@rsbuild/plugin-react';
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).
*
@@ -18,13 +21,25 @@ import { pluginDts } from 'rsbuild-plugin-dts';
* ```
*/
export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
return defineConfig({
plugins: [pluginReact(), pluginTypeCheck()],
const defaultConfig: RsbuildConfig = {
plugins: [pluginReact(), pluginTypeCheck(), ssgPlugin()],
output: {
distPath: {
font: 'static/font',
},
filename: {
font: '[name][ext]',
},
},
html: {
template: './src/static/index.html',
meta: {
description: 'Thoughts and creations of mine',
},
},
...config,
});
};
return defineConfig(mergeConfig(defaultConfig, config));
}
/**
@@ -42,7 +57,7 @@ export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
* ```
*/
export function definePackageConfig(config: RsbuildConfig = {}): RsbuildConfig {
return defineConfig({
const defaultConfig: RsbuildConfig = {
plugins: [pluginReact(), pluginTypeCheck(), pluginDts()],
output: {
distPath: {
@@ -54,6 +69,7 @@ export function definePackageConfig(config: RsbuildConfig = {}): RsbuildConfig {
tools: {
htmlPlugin: false,
},
...config,
});
};
return defineConfig(mergeConfig(defaultConfig, config));
}
@@ -0,0 +1,185 @@
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import React from 'react';
import type { RsbuildPlugin } from '@rsbuild/core';
/**
* Rsbuild plugin for build-time Static Site Generation.
*
* After the web build completes:
* 1. Builds each entry as a Node.js CJS bundle (dist-ssr/)
* 2. Renders each entry with react-dom/server renderToString
* 3. Injects the rendered HTML into the web build's HTML files
* 4. Runs critters to inline critical CSS and make the main stylesheet async
* 5. Cleans up dist-ssr/
*/
export function ssgPlugin(): RsbuildPlugin {
return {
name: 'ermnvldmr:ssg-plugin',
setup(api) {
api.onAfterBuild(async ({ stats }) => {
if (!stats) {
console.warn('[ssg-plugin] No stats available, skipping SSG.');
return;
}
const config = api.getRsbuildConfig();
const rawEntries = config.source?.entry;
if (!rawEntries || typeof rawEntries !== 'object' || Array.isArray(rawEntries)) {
console.warn('[ssg-plugin] source.entry is not an object map, skipping SSG.');
return;
}
// Normalise entries: value may be a string path or an EntryDescription object
const entries: Record<string, string> = {};
for (const [name, value] of Object.entries(rawEntries)) {
if (typeof value === 'string') {
entries[name] = value;
} else if (value && typeof value === 'object' && 'import' in value) {
const imp = (value as { import: string | string[] }).import;
entries[name] = Array.isArray(imp) ? imp[0] : imp;
}
}
const entryNames = Object.keys(entries);
if (entryNames.length === 0) {
console.warn('[ssg-plugin] No entries to process, skipping SSG.');
return;
}
// api.context.distPath is the absolute path to the web build output directory.
// Fallback: derive from rootPath + output.distPath.root if the property is absent.
const webDistPath: string =
(api.context as { distPath?: string }).distPath ??
path.join(
api.context.rootPath,
(config.output?.distPath as { root?: string } | undefined)?.root ?? 'dist'
);
const ssrDistPath = path.join(path.dirname(webDistPath), 'dist-ssr');
// ── Step 1: Build node-target CJS bundles ─────────────────────────────
console.log('[ssg-plugin] Building SSR bundles...');
const { createRsbuild } = await import('@rsbuild/core');
// Reuse web build plugins minus ssgPlugin (avoids recursion) and pluginTypeCheck
// (type-checking is not needed for the SSR build). This automatically includes
// pluginReact, pluginMdx, and any other service-level plugins.
const SSG_PLUGIN_NAME = 'ermnvldmr:ssg-plugin';
const TYPE_CHECK_PLUGIN_NAME = 'rsbuild:type-check';
const ssrPlugins = (config.plugins ?? []).filter((p) => {
if (!p || typeof p !== 'object') return true;
const name = (p as { name?: string }).name;
return name !== SSG_PLUGIN_NAME && name !== TYPE_CHECK_PLUGIN_NAME;
});
const ssrBuild = await createRsbuild({
rsbuildConfig: {
plugins: ssrPlugins,
source: { entry: entries },
tools: config.tools,
output: {
target: 'node',
distPath: { root: ssrDistPath, js: '.' },
filename: { js: '[name].cjs' },
// Externalize React so the SSR bundle shares the same React instance
// as react-dom/server, preventing "null dispatcher" hook errors.
externals: {
react: 'commonjs react',
'react-dom': 'commonjs react-dom',
'react/jsx-runtime': 'commonjs react/jsx-runtime',
'react/jsx-dev-runtime': 'commonjs react/jsx-dev-runtime',
},
},
},
});
await ssrBuild.build();
console.log('[ssg-plugin] SSR bundles ready.');
// ── Step 2: Render, inject, inline critical CSS ───────────────────────
const { renderToString } = await import('react-dom/server');
// createRequire from ESM context — needed to load CJS modules (critters, SSR bundles)
const _require = createRequire(import.meta.url);
const Critters = _require('critters') as new (opts?: {
path?: string;
publicPath?: string;
preload?: string;
inlineFonts?: boolean;
pruneSource?: boolean;
reduceInlineStyles?: boolean;
}) => { process(html: string): Promise<string> };
// critters `path` is the filesystem root that corresponds to publicPath.
// It resolves <link href="..."> to webDistPath. Use configured assetPrefix for locale builds.
const configuredAssetPrefix =
(config.output as { assetPrefix?: string } | undefined)?.assetPrefix ?? '/';
const critters = new Critters({
path: webDistPath,
publicPath: configuredAssetPrefix,
preload: 'swap', // <link rel="preload" as="style" onload="this.rel='stylesheet'">
inlineFonts: false, // fonts are handled via separate <link rel="preload"> tags
pruneSource: false, // keep full CSS file for post-hydration correctness
reduceInlineStyles: false, // preserve theme init styles (prevents .dark rule from being stripped)
});
for (const entryName of entryNames) {
const ssrBundlePath = path.join(ssrDistPath, `${entryName}.cjs`);
const webHtmlPath = path.join(webDistPath, `${entryName}.html`);
// Clear module cache so each entry loads fresh
const resolved = _require.resolve(ssrBundlePath);
delete _require.cache[resolved];
const mod = _require(ssrBundlePath) as { default?: React.ComponentType };
const Component = mod.default;
if (!Component) {
console.warn(`[ssg-plugin] Entry "${entryName}" has no default export — skipping.`);
continue;
}
// Render to HTML string
const renderedHtml = renderToString(
React.createElement(React.StrictMode, null, React.createElement(Component))
);
// Read web HTML, inject rendered content
const source = await fs.readFile(webHtmlPath, 'utf8');
const injected = source.replace(
/<div id="root"[^>]*>\s*<\/div>/,
`<div id="root">${renderedHtml}</div>`
);
if (injected === source) {
console.warn(
`[ssg-plugin] Could not find <div id="root"> in ${webHtmlPath} — injection skipped.`
);
}
// Run critters: inline critical CSS, make main stylesheet async
const processed = await critters.process(injected);
// Set <html lang> to the current build locale
const locale = process.env.LOCALE ?? 'en';
const localized = processed.replace(
/<html([^>]*)\slang="[^"]*"/,
`<html$1 lang="${locale}"`
);
await fs.writeFile(webHtmlPath, localized, 'utf8');
console.log(`[ssg-plugin] ✓ ${entryName}`);
}
// ── Step 3: Cleanup ───────────────────────────────────────────────────
await fs.rm(ssrDistPath, { recursive: true, force: true });
console.log('[ssg-plugin] SSG complete. dist-ssr cleaned up.');
});
},
};
}
+1
View File
@@ -1 +1,2 @@
export * from './config/index.js';
export { mergeConfig } from './utils/merge.js';
@@ -0,0 +1,35 @@
/**
* Minimal deep merge utility for Rsbuild configurations.
* Concatenates arrays and recursively merges objects.
*
* @param source - The source object
* @param target - The target object to merge into source
* @returns The merged object
*/
export function mergeConfig<T>(source: any, target: any): T {
if (target === undefined) return source;
if (source === undefined) return target;
if (Array.isArray(source) && Array.isArray(target)) {
return [...source, ...target] as any;
}
if (
source !== null &&
typeof source === 'object' &&
!Array.isArray(source) &&
target !== null &&
typeof target === 'object' &&
!Array.isArray(target)
) {
const result = { ...source };
for (const key in target) {
if (Object.prototype.hasOwnProperty.call(target, key)) {
result[key] = mergeConfig(source[key], target[key]);
}
}
return result as any;
}
return target;
}
+6 -3
View File
@@ -8,15 +8,18 @@
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
"types": "./dist/index.d.ts",
"default": "./src/index.ts"
},
"./testing": {
"import": "./dist/testing.js",
"types": "./dist/testing.d.ts"
"types": "./dist/testing.d.ts",
"default": "./src/testing.ts"
},
"./dev": {
"import": "./dist/dev.js",
"types": "./dist/dev.d.ts"
"types": "./dist/dev.d.ts",
"default": "./src/dev.ts"
}
},
"files": [
+20 -1
View File
@@ -1,5 +1,20 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { extendTailwindMerge } from 'tailwind-merge';
const RB_TYPES = ['display', 'headline', 'title', 'body', 'label'] as const;
const RB_SIZES = ['l', 'm', 's'] as const;
const rbFontSizeClasses = RB_TYPES.flatMap((type) =>
RB_SIZES.map((size) => `${type}-${size}`)
);
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': [{ 'text-rb': rbFontSizeClasses }],
},
},
});
/**
* Combines and merges CSS class names using clsx and tailwind-merge.
@@ -7,6 +22,10 @@ import { twMerge } from 'tailwind-merge';
* This utility function combines the power of clsx for conditional class handling
* with tailwind-merge for proper Tailwind CSS class deduplication and conflict resolution.
*
* Tailwind-merge is configured to recognise `text-rb-{type}-{size}` classes
* (e.g. `text-rb-body-m`) as font-size utilities so they do not conflict with
* text-color classes such as `text-[var(--rb-text)]`.
*
* @param inputs - Class values to combine (strings, objects, arrays, etc.)
* @returns Merged class string with conflicts resolved
*
+1
View File
@@ -1,4 +1,5 @@
export * from './types/index.js';
export * from './functions/index.js';
export * from './object/index.js';
export * from './window/localStorage.js';
export * from './react/index.js';
+6
View File
@@ -0,0 +1,6 @@
/**
* Object utilities for common operations on objects.
*/
export { merge } from './merge/merge';
export type { MergeOptions } from './merge/merge';
@@ -0,0 +1,108 @@
import { merge } from './merge';
/**
* Test suite for the merge utility function.
*/
describe('shared/helpers/object/merge', () => {
it('deeply merges two objects, preserving source values by default', () => {
const source = { a: 1, b: { c: 2 } };
const target = { a: 3, b: { c: 4, d: 5 }, e: 6 };
const result = merge(source, target);
expect(result).toEqual({
a: 1, // force is false, so 1 is preserved
b: {
c: 2, // force is false, so 2 is preserved
d: 5, // added because it was not in source
},
e: 6, // added because it was not in source
});
});
it('deeply merges two objects and overrides values when force is true', () => {
const source = { a: 1, b: { c: 2 } };
const target = { a: 3, b: { c: 4, d: 5 }, e: 6 };
const result = merge(source, target, { force: true });
expect(result).toEqual({
a: 3,
b: {
c: 4,
d: 5,
},
e: 6,
});
});
it('concatenates arrays when both are present at the same key', () => {
const source = { a: [1, 2] };
const target = { a: [3, 4] };
const result = merge(source, target);
expect(result).toEqual({ a: [1, 2, 3, 4] });
});
it('handles primitive values at the root', () => {
expect(merge(1, 2)).toBe(1);
expect(merge(1, 2, { force: true })).toBe(2);
});
it('handles mixed types between source and target', () => {
const source = { a: { b: 1 } };
const target = { a: [1, 2] };
// Source wins because force is false
expect(merge(source, target)).toEqual({ a: { b: 1 } });
// Target wins because force is true
expect(merge(source, target, { force: true })).toEqual({ a: [1, 2] });
});
it('handles deep recursion with arrays and objects', () => {
const source = {
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
],
config: {
retries: 3,
enabled: true,
},
};
const target = {
users: [{ id: 3, name: 'Doe' }],
config: {
retries: 5,
timeout: 1000,
},
};
const result = merge(source, target);
expect(result).toEqual({
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
{ id: 3, name: 'Doe' },
],
config: {
retries: 3,
enabled: true,
timeout: 1000,
},
});
});
it('preserves the source when target has undefined values', () => {
const source = { a: 1 };
const target = { a: undefined };
expect(merge(source, target)).toEqual({ a: 1 });
});
it('replaces undefined source with target', () => {
const source = { a: undefined };
const target = { a: 1 };
expect(merge(source, target)).toEqual({ a: 1 });
});
});
@@ -0,0 +1,76 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unnecessary-type-assertion */
/**
* Options for the merge utility.
*/
export interface MergeOptions {
/**
* Whether to override existing primitive values in the source with values from the target.
* If false, existing primitive values are preserved.
* @default false
*/
force?: boolean;
}
/**
* Deeply merges two values (objects, arrays, or primitives).
*
* Behavior:
* - If both values are arrays, they are concatenated.
* - If both values are objects, they are merged recursively.
* - If encounter a primitive value, it is overridden only if `force` is set to true.
* - If a key exists in target but not in source, it is added to the result.
*
* @param source - The source value to merge from
* @param target - The target value to merge with
* @param options - Merge configuration options
* @returns The merged result
*
* @example
* ```typescript
* merge({ a: 1 }, { a: 2 }) // { a: 1 }
* merge({ a: 1 }, { a: 2 }, { force: true }) // { a: 2 }
* merge({ a: [1] }, { a: [2] }) // { a: [1, 2] }
* merge({ a: { b: 1 } }, { a: { c: 2 } }) // { a: { b: 1, c: 2 } }
* ```
*/
export function merge<T = any>(source: any, target: any, options: MergeOptions = {}): T {
const { force = false } = options;
// If target is undefined, source wins
if (target === undefined) {
return source;
}
// If source is undefined, target wins
if (source === undefined) {
return target;
}
const sourceIsArray = Array.isArray(source);
const targetIsArray = Array.isArray(target);
// If both are arrays, concatenate
if (sourceIsArray && targetIsArray) {
return [...source, ...target] as any;
}
const sourceIsObject = source !== null && typeof source === 'object' && !sourceIsArray;
const targetIsObject = target !== null && typeof target === 'object' && !targetIsArray;
// If both are objects, deep merge
if (sourceIsObject && targetIsObject) {
const result = { ...source };
for (const key in target) {
if (Object.prototype.hasOwnProperty.call(target, key)) {
if (key in source) {
result[key] = merge(source[key], target[key], options);
} else {
result[key] = target[key];
}
}
}
return result as any;
}
// If we encounter primitive values or mixed types, override only if force is true
return force ? target : source;
}
@@ -0,0 +1,20 @@
import type React from 'react';
/**
* Safely casts a React ref to a mutable ref object.
* This is a controlled escape hatch for situations where TypeScript's
* built-in Ref types are too restrictive (e.g., when syncing refs manually).
*
* @param ref - The ref to cast
* @returns The casted mutable ref
* @example
* ```tsx
* const mutableRef = castMutableRef<HTMLDivElement>(ref);
* mutableRef.current = someElement;
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function castMutableRef<T>(ref: any): React.MutableRefObject<T | null> {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return ref as React.MutableRefObject<T | null>;
}
+5
View File
@@ -1,4 +1,9 @@
export { castRef } from './castRef/castRef';
export { castMutableRef } from './castRef/castMutableRef';
export { genericMemo } from './genericMemo/genericMemo';
export { useResizeObserver } from './useResizeObserver/useResizeObserver';
export { useIntersectionObserver } from './useIntersectionObserver/useIntersectionObserver';
export { useScroll } from './useScroll/useScroll';
export { useLocalStorage } from './useLocalStorage/useLocalStorage';
export type { ReactElementProps } from './types/ReactElementProps';
@@ -0,0 +1,139 @@
import { renderHook, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { useIntersectionObserver } from './useIntersectionObserver';
describe('useIntersectionObserver', () => {
let observerCallback: IntersectionObserverCallback;
let observeMock = vi.fn();
let unobserveMock = vi.fn();
let disconnectMock = vi.fn();
beforeEach(() => {
observeMock = vi.fn();
unobserveMock = vi.fn();
disconnectMock = vi.fn();
class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
observerCallback = callback;
this.root = options?.root ?? null;
this.rootMargin = options?.rootMargin ?? '';
this.thresholds = options?.threshold
? Array.isArray(options.threshold)
? options.threshold
: [options.threshold]
: [0];
}
root: Element | Document | null;
rootMargin: string;
thresholds: readonly number[];
takeRecords = vi.fn();
observe = observeMock;
unobserve = unobserveMock;
disconnect = disconnectMock;
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('should return initial state as not intersecting', () => {
const ref = { current: document.createElement('div') };
const { result } = renderHook(() => useIntersectionObserver(ref));
expect(result.current).toBe(false);
});
it('should call observe on mount', () => {
const element = document.createElement('div');
const ref = { current: element };
renderHook(() => useIntersectionObserver(ref));
expect(observeMock).toHaveBeenCalledWith(element);
});
it('should update isIntersecting when observer triggers', () => {
const element = document.createElement('div');
const ref = { current: element };
const { result } = renderHook(() => useIntersectionObserver(ref));
const entries = [
{
isIntersecting: true,
target: element,
},
] as unknown as IntersectionObserverEntry[];
act(() => {
observerCallback(entries, {} as IntersectionObserver);
});
expect(result.current).toBe(true);
});
it('should disconnect after first intersection if once is true', () => {
const element = document.createElement('div');
const ref = { current: element };
renderHook(() => useIntersectionObserver(ref, { once: true }));
const entries = [
{
isIntersecting: true,
target: element,
},
] as unknown as IntersectionObserverEntry[];
act(() => {
observerCallback(entries, {} as IntersectionObserver);
});
expect(disconnectMock).toHaveBeenCalled();
});
it('should not disconnect after first intersection if once is false', () => {
const element = document.createElement('div');
const ref = { current: element };
renderHook(() => useIntersectionObserver(ref, { once: false }));
const entries = [
{
isIntersecting: true,
target: element,
},
] as unknown as IntersectionObserverEntry[];
act(() => {
observerCallback(entries, {} as IntersectionObserver);
});
expect(disconnectMock).not.toHaveBeenCalled();
});
it('should call disconnect on unmount', () => {
const element = document.createElement('div');
const ref = { current: element };
const { unmount } = renderHook(() => useIntersectionObserver(ref));
unmount();
expect(disconnectMock).toHaveBeenCalled();
});
it('should not observe if ref is null', () => {
const ref = { current: null };
renderHook(() => useIntersectionObserver(ref));
expect(observeMock).not.toHaveBeenCalled();
});
it('should return true immediately if IntersectionObserver is not available', () => {
vi.stubGlobal('IntersectionObserver', undefined);
const ref = { current: document.createElement('div') };
const { result } = renderHook(() => useIntersectionObserver(ref));
expect(result.current).toBe(true);
});
});
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
/** Options for useIntersectionObserver */
export interface UseIntersectionObserverOptions extends IntersectionObserverInit {
/** Whether to disconnect after the first intersection */
once?: boolean;
}
/**
* A hook that observes if an element is intersecting with its root.
*
* @param ref - A React ref to the element to observe.
* @param options - IntersectionObserver configuration.
* @returns Boolean indicating if the element is currently intersecting.
*
* @example
* ```tsx
* const MyComponent = () => {
* const ref = useRef<HTMLDivElement>(null);
* const isVisible = useIntersectionObserver(ref);
*
* return <div ref={ref}>{isVisible ? 'I am visible!' : 'Scroll down...'}</div>;
* };
* ```
*/
export const useIntersectionObserver = <T extends HTMLElement>(
ref: React.RefObject<T | null>,
{ once = true, ...options }: UseIntersectionObserverOptions = {}
): boolean => {
const [isIntersecting, setIntersecting] = useState(false);
const { root, rootMargin, threshold } = options;
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') {
setIntersecting(true);
return;
}
const observeTarget = ref.current;
if (!observeTarget) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
setIntersecting(entry.isIntersecting);
if (entry.isIntersecting && once) {
observer.disconnect();
}
},
{ root, rootMargin, threshold }
);
observer.observe(observeTarget);
return () => {
observer.disconnect();
};
}, [ref, once, root, rootMargin, threshold]);
return isIntersecting;
};
@@ -25,11 +25,11 @@ describe('lib/react/useLocalStorage', () => {
it('should update state when setter is called', () => {
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
const { result } = renderHook(() => useLocalStorage(manager));
act(() => {
result.current[1]('new value');
});
expect(result.current[0]).toBe('new value');
expect(window.localStorage.getItem(KEY)).toBe(JSON.stringify('new value'));
});
@@ -39,9 +39,8 @@ export function useLocalStorage<T>(
const getSnapshot = useCallback(() => manager.get(), [manager]);
const value = useSyncExternalStore(subscribe, getSnapshot, () => {
// During SSR, we can't access localStorage.
// However, createLocalStorage is designed to throw if accessed during SSR.
// This hook is intended to be used in client-side components.
// SSR snapshot: createLocalStorage returns a no-op manager in Node context,
// so manager.get() safely returns defaultValue without accessing localStorage.
return manager.get();
});
@@ -0,0 +1,79 @@
import { renderHook, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { useResizeObserver } from './useResizeObserver';
describe('useResizeObserver', () => {
let observerCallback: ResizeObserverCallback;
const observeMock = vi.fn();
const unobserveMock = vi.fn();
const disconnectMock = vi.fn();
beforeEach(() => {
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
observerCallback = callback;
}
observe = observeMock;
unobserve = unobserveMock;
disconnect = disconnectMock;
}
vi.stubGlobal('ResizeObserver', MockResizeObserver);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('should return initial dimensions', () => {
const ref = { current: document.createElement('div') };
const { result } = renderHook(() => useResizeObserver(ref));
expect(result.current).toEqual({ width: 0, height: 0 });
});
it('should call observe on mount', () => {
const element = document.createElement('div');
const ref = { current: element };
renderHook(() => useResizeObserver(ref));
expect(observeMock).toHaveBeenCalledWith(element);
});
it('should update dimensions when ResizeObserver triggers', () => {
const element = document.createElement('div');
const ref = { current: element };
const { result } = renderHook(() => useResizeObserver(ref));
const entries = [
{
contentRect: { width: 100, height: 200 },
target: element,
},
] as unknown as ResizeObserverEntry[];
act(() => {
observerCallback(entries, {} as ResizeObserver);
});
expect(result.current).toEqual({ width: 100, height: 200 });
});
it('should call disconnect on unmount', () => {
const element = document.createElement('div');
const ref = { current: element };
const { unmount } = renderHook(() => useResizeObserver(ref));
unmount();
expect(disconnectMock).toHaveBeenCalled();
});
it('should not observe if ref is null', () => {
const ref = { current: null };
renderHook(() => useResizeObserver(ref));
expect(observeMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
/** Result of useResizeObserver */
export interface UseResizeObserverResult {
/** Observed width */
width: number;
/** Observed height */
height: number;
}
/**
* A hook that observes the size of an element and returns its dimensions.
*
* @param ref - A React ref to the element to observe.
* @returns The dimensions of the observed element.
*
* @example
* ```tsx
* const MyComponent = () => {
* const ref = useRef<HTMLDivElement>(null);
* const { width, height } = useResizeObserver(ref);
*
* return <div ref={ref}>Width: {width}, Height: {height}</div>;
* };
* ```
*/
export const useResizeObserver = <T extends HTMLElement>(
ref: React.RefObject<T | null>
): UseResizeObserverResult => {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
const observeTarget = ref.current;
if (!observeTarget) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) {
return;
}
const entry = entries[0];
setDimensions({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
});
resizeObserver.observe(observeTarget);
return () => {
resizeObserver.disconnect();
};
}, [ref]);
return dimensions;
};
@@ -0,0 +1,70 @@
import { renderHook, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { useScroll } from './useScroll';
describe('useScroll', () => {
const originalScrollX = window.scrollX;
const originalScrollY = window.scrollY;
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
return setTimeout(callback, 0);
});
vi.useFakeTimers();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
window.scrollX = originalScrollX;
window.scrollY = originalScrollY;
});
it('should return initial scroll position', () => {
window.scrollX = 50;
window.scrollY = 150;
const { result } = renderHook(() => useScroll());
expect(result.current).toEqual({ x: 50, y: 150 });
});
it('should update scroll position on scroll', () => {
const { result } = renderHook(() => useScroll());
act(() => {
window.scrollX = 100;
window.scrollY = 200;
window.dispatchEvent(new Event('scroll'));
});
// Wait for requestAnimationFrame (mocked via setTimeout)
act(() => {
vi.runAllTimers();
});
expect(result.current).toEqual({ x: 100, y: 200 });
});
it('should throttle updates using requestAnimationFrame', () => {
const { result } = renderHook(() => useScroll());
act(() => {
window.scrollX = 100;
window.scrollY = 200;
window.dispatchEvent(new Event('scroll'));
window.scrollX = 300;
window.scrollY = 400;
window.dispatchEvent(new Event('scroll'));
});
// Before timers run, it should still be initial or first set if not throttled correctly
// But since we trigger twice in same tick, ticking.current prevents second RAF.
act(() => {
vi.runAllTimers();
});
expect(result.current).toEqual({ x: 300, y: 400 });
});
});
@@ -0,0 +1,44 @@
import { useState, useEffect, useRef } from 'react';
/** Result of useScroll */
export interface UseScrollResult {
/** x scroll position */
x: number;
/** y scroll position */
y: number;
}
/**
* A hook to track window scroll position.
*
* @returns An object containing the x and y scroll position.
*
* @example
* ```tsx
* const { x, y } = useScroll();
* ```
*/
export const useScroll = (): UseScrollResult => {
const [scroll, setScroll] = useState(() => ({
x: typeof window !== 'undefined' ? window.scrollX : 0,
y: typeof window !== 'undefined' ? window.scrollY : 0,
}));
const ticking = useRef(false);
useEffect(() => {
const handleScroll = () => {
if (!ticking.current) {
window.requestAnimationFrame(() => {
setScroll({ x: window.scrollX, y: window.scrollY });
ticking.current = false;
});
ticking.current = true;
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return scroll;
};
@@ -29,11 +29,14 @@ describe('lib/window/localStorage', () => {
},
dispatchEvent: vi.fn(),
});
vi.stubGlobal('StorageEvent', class {
constructor(type: string, init: any) {
Object.assign(this, { type, ...init });
vi.stubGlobal(
'StorageEvent',
class {
constructor(type: string, init: any) {
Object.assign(this, { type, ...init });
}
}
});
);
});
afterEach(() => {
@@ -41,9 +44,15 @@ describe('lib/window/localStorage', () => {
for (const key in mockStorage) delete mockStorage[key];
});
it('should throw if window is undefined', () => {
it('returns a no-op manager when window is undefined (SSR/SSG context)', () => {
vi.stubGlobal('window', undefined);
expect(() => createLocalStorage(KEY, DEFAULT_VALUE)).toThrow();
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
expect(manager.key).toBe(KEY);
expect(manager.get()).toEqual(DEFAULT_VALUE);
expect(() => manager.set({ foo: 'x' })).not.toThrow();
expect(() => manager.remove()).not.toThrow();
// get() still returns defaultValue after set (no-op in SSR)
expect(manager.get()).toEqual(DEFAULT_VALUE);
});
it('should get default value if empty', () => {
+12 -8
View File
@@ -17,7 +17,8 @@ export interface StorageManager<T> {
/**
* Creates a type-safe manager for a localStorage key.
*
* This utility is browser-only and will throw if accessed during SSR.
* In SSR/SSG context (no window), returns a no-op manager that always
* returns the default value. In the browser, uses localStorage as usual.
* Ensure you use a strict namespace for the key to avoid monorepo collisions.
* Recommended format: `ermnvldmr/{package}/{path}/{module}`
* Example: `ermnvldmr/ui/lib/theme`
@@ -31,15 +32,15 @@ export interface StorageManager<T> {
* const theme = createLocalStorage('ermnvldmr/ui/lib/theme', 'system');
* console.log(theme.get());
* ```
*
* @throws Error if window or localStorage is undefined.
*/
export function createLocalStorage<T>(key: string, defaultValue: T): StorageManager<T> {
if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
throw new Error(
`[createLocalStorage] Attempted to access storage for key "${key}" outside of a browser environment. ` +
'This utility is strictly for client-side use.'
);
return {
key,
get: () => defaultValue,
set: () => { /* no-op in SSR/SSG context */ },
remove: () => { /* no-op in SSR/SSG context */ },
};
}
const manager: StorageManager<T> = {
@@ -51,7 +52,10 @@ export function createLocalStorage<T>(key: string, defaultValue: T): StorageMana
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return JSON.parse(item) as T;
} catch (error) {
console.error(`[createLocalStorage] Failed to parse key "${key}", falling back to default.`, error);
console.error(
`[createLocalStorage] Failed to parse key "${key}", falling back to default.`,
error
);
return defaultValue;
}
},
+39
View File
@@ -1 +1,40 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
class ResizeObserverMock {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
global.ResizeObserver = ResizeObserverMock;
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// jsdom sometimes does not expose localStorage.clear(); provide a functional polyfill
if (typeof window.localStorage.clear !== 'function') {
const store = new Map<string, string>();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
getItem: (key: string): string | null => store.get(key) ?? null,
setItem: (key: string, value: string): void => { store.set(key, value); },
removeItem: (key: string): void => { store.delete(key); },
clear: (): void => { store.clear(); },
key: (index: number): string | null => [...store.keys()][index] ?? null,
get length(): number { return store.size; },
},
});
}
+10 -4
View File
@@ -8,15 +8,19 @@
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/ui/src/index.d.ts"
"style": "./dist/styles/*.css",
"types": "./dist/ui/src/index.d.ts",
"default": "./src/index.ts"
},
"./testing": {
"import": "./dist/testing.js",
"types": "./dist/ui/src/testing.d.ts"
"types": "./dist/ui/src/testing.d.ts",
"default": "./src/testing.ts"
},
"./dev": {
"import": "./dist/dev.js",
"types": "./dist/ui/src/dev.d.ts"
"types": "./dist/ui/src/dev.d.ts",
"default": "./src/dev.ts"
}
},
"files": [
@@ -37,6 +41,7 @@
"clean": "rm -rf dist coverage storybook-static"
},
"dependencies": {
"@ermnvldmr/i18n": "workspace:*",
"@ermnvldmr/stl": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.12",
@@ -47,6 +52,7 @@
"react-aria": "^3.45.0",
"react-icons": "^5.5.0",
"react-stately": "^3.44.0",
"shiki": "^4.0.1",
"tw-animate-css": "^1.2.8"
},
"devDependencies": {
@@ -77,7 +83,7 @@
"jsdom": "^28.0.0",
"playwright": "^1.57.0",
"postcss": "^8.5.6",
"storybook": "^8.6.15",
"storybook": "^8.6.17",
"storybook-react-rsbuild": "^1.0.3",
"typescript": "^5.8.3",
"vitest": "^4.0.18",
@@ -8,6 +8,7 @@ const meta: Meta<typeof Article> = {
tags: ['autodocs'],
argTypes: {
onPress: { action: 'pressed' },
href: { control: 'text' },
},
};
@@ -35,9 +36,9 @@ export const Full: Story = {
},
};
export const Interactive: Story = {
export const Linked: Story = {
args: {
...Full.args,
onPress: () => alert('Article clicked!'),
href: '#',
},
};
@@ -19,17 +19,31 @@ describe('components/Article', () => {
expect(screen.getByText('Reporting by John Doe')).toBeInTheDocument();
});
it('renders separators only when appropriate', () => {
it('renders separators correctly based on subHeadline presence', () => {
const { rerender } = render(<Article headline="Title">Body</Article>);
// Should have 1 separator (between title and body)
expect(screen.getAllByRole('separator')).toHaveLength(1);
let separators = screen.getAllByRole('separator');
expect(separators).toHaveLength(1);
expect(separators[0].className).toContain('mask-image');
rerender(
<Article headline="Title" subHeadline="Sub">
Body
</Article>
);
// Should have 2 separators (Title-Sub and Sub-Body)
expect(screen.getAllByRole('separator')).toHaveLength(2);
separators = screen.getAllByRole('separator');
expect(separators).toHaveLength(1);
expect(separators[0].className).toContain('mask-image');
});
it('renders as a link when href is provided', () => {
render(
<Article {...props} href="/test-article">
Article body content
</Article>
);
const articleContainer = screen.getByText('Breaking News').closest('a');
expect(articleContainer).toBeInTheDocument();
expect(articleContainer).toHaveAttribute('href', '/test-article');
});
});
+32 -25
View File
@@ -7,19 +7,28 @@ import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { ContainerProps } from '../Container/Container';
import type { ClassNameProps } from '@ermnvldmr/stl';
/**
* Props for the Article component.
*/
export interface ArticleProps {
export interface ArticleProps extends ClassNameProps {
/** The main article headline */
headline: string;
/** Optional sub-headline displayed between separators */
subHeadline?: string;
/** Optional additional text (e.g., author) displayed at the bottom in gray italic label */
additionalText?: string;
/** Press interaction handler propagated to the wrapping Container */
/**
* Press interaction handler.
* Cannot be used with `href`.
*/
onPress?: ContainerProps['onPress'];
/**
* If provided, the container will be rendered as an `<a>` tag for navigation.
* Cannot be used with `onPress`.
*/
href?: ContainerProps['href'];
/** The main article body content */
children: React.ReactNode;
}
@@ -28,45 +37,43 @@ export interface ArticleProps {
* A article component with a structured layout.
*
* Features:
* - Centered medium headline (headline-m)
* - Single-line separators
* - Optional centered italic sub-headline (label-m)
* - Left-aligned medium headline (headline-m)
* - Single-line separators with optional end fade
* - Optional italic sub-headline (label-m)
* - Main body text (body-m)
* - Optional bottom gray italic label (label-s)
* - Interactive wrapper via Container
* - Interactive wrapper via Container, supporting both `onPress` and `href`
*/
export const Article = memo(function Article({
headline,
subHeadline,
additionalText,
onPress,
href,
children,
className,
}: ArticleProps) {
return (
<Container padding={4} onPress={onPress}>
<Container border className={className} href={href} padding={4} rounded="md" onPress={onPress}>
<VStack align="stretch" gap={4}>
<Header className="text-center" level={4}>
{headline}
</Header>
<VStack align="center" gap={2}>
{subHeadline ? (
<>
<Separator className="w-3/4" type="single" />
<Text italic className="text-center" size="m" type="label">
{subHeadline}
</Text>
<Separator className="w-3/4" type="single" />
</>
) : (
<Separator className="w-3/4" type="single" />
)}
<VStack align="stretch" gap={2}>
<Header level={4}>{headline}</Header>
<VStack align="stretch" gap={2}>
{subHeadline ? (
<>
<Text italic size="m" type="label">
{subHeadline}
</Text>
<Separator thinned="thinned-end" type="single" />
</>
) : (
<Separator thinned="thinned-end" type="single" />
)}
</VStack>
</VStack>
<Text size="m" type="body">
{children}
</Text>
{additionalText && (
<VStack align="end">
<Text italic color="muted" size="s" type="label">
@@ -33,6 +33,18 @@ export const Default: Story = {
),
};
export const Interactive: Story = {
render: () => (
<Breadcrumbs>
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="arrow" />
<Breadcrumbs.Item onClick={() => alert('Clicked!')}>Click Me</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="arrow" />
<Breadcrumbs.Item isCurrent>Current Page</Breadcrumbs.Item>
</Breadcrumbs>
),
};
export const WithArrows: Story = {
render: () => (
<Breadcrumbs>
@@ -1,7 +1,7 @@
import React from 'react';
import { Breadcrumbs } from './Breadcrumbs';
import { render, screen } from '../../lib/testing';
import { render, screen, fireEvent } from '../../lib/testing';
import { Link } from '../Link/Link';
describe('components/Breadcrumbs', () => {
@@ -12,14 +12,58 @@ describe('components/Breadcrumbs', () => {
<Link href="/">Home</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="slash" />
<Breadcrumbs.Item>Current</Breadcrumbs.Item>
<Breadcrumbs.Item isCurrent>Current</Breadcrumbs.Item>
</Breadcrumbs>
);
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Breadcrumb');
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
expect(screen.getByText('/')).toBeInTheDocument();
expect(screen.getByText('Current')).toBeInTheDocument();
const currentItem = screen.getByText('Current').parentElement;
expect(currentItem).toHaveAttribute('aria-current', 'page');
});
it('supports isCurrent state', () => {
render(
<Breadcrumbs>
<Breadcrumbs.Item isCurrent>Current Page</Breadcrumbs.Item>
</Breadcrumbs>
);
const item = screen.getByText('Current Page').parentElement;
expect(item).toHaveAttribute('aria-current', 'page');
});
it('supports href directly on Item', () => {
render(
<Breadcrumbs>
<Breadcrumbs.Item href="/test-url">Test Link</Breadcrumbs.Item>
</Breadcrumbs>
);
const link = screen.getByRole('link', { name: 'Test Link' });
expect(link).toHaveAttribute('href', '/en/test-url');
});
it('supports onClick on Item', () => {
const handleClick = vi.fn();
render(
<Breadcrumbs>
<Breadcrumbs.Item onClick={handleClick}>Clickable</Breadcrumbs.Item>
</Breadcrumbs>
);
const button = screen.getByRole('button', { name: 'Clickable' });
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('renders as a non-submit button when onClick is provided', () => {
render(
<Breadcrumbs>
<Breadcrumbs.Item onClick={vi.fn()}>Clickable</Breadcrumbs.Item>
</Breadcrumbs>
);
const button = screen.getByRole('button', { name: 'Clickable' });
expect(button).toHaveAttribute('type', 'button');
});
it('supports different separator variants', () => {
@@ -1,3 +1,4 @@
import { localePath } from '@ermnvldmr/i18n';
import { cn } from '@ermnvldmr/stl';
import { ChevronRight } from 'lucide-react';
import React, { memo } from 'react';
@@ -29,20 +30,58 @@ const BreadcrumbsRoot = memo(function Breadcrumbs({ children, ...props }: Breadc
);
});
/**
* Props for the individual Breadcrumbs item.
*/
export interface BreadcrumbsItemProps extends TestIdProps {
/** Content to be rendered inside the item */
children: React.ReactNode;
/** Optional className for the list item */
className?: string;
/** Whether this is the current page */
isCurrent?: boolean;
/** Optional href for the item (renders as a link) */
href?: string;
/** Optional click handler for the item */
onClick?: React.MouseEventHandler<HTMLElement>;
}
/**
* Individual step in the breadcrumb trail.
*/
const BreadcrumbsItem = memo(function BreadcrumbsItem({
children,
className,
isCurrent,
href,
onClick,
'data-testid': testId,
}: {
children: React.ReactNode;
className?: string;
} & TestIdProps) {
}: BreadcrumbsItemProps) {
const resolvedHref = localePath(href);
const isClickable = !!(resolvedHref ?? onClick);
return (
<li className={className} data-testid={testId}>
<Text color="muted" size="m" type="label">
<li
aria-current={isCurrent ? 'page' : undefined}
className={cn(
'group flex items-center transition-all duration-200',
!isCurrent && isClickable && 'cursor-pointer',
className
)}
data-testid={testId}
>
<Text
as={resolvedHref ? 'a' : onClick ? 'button' : 'span'}
className={cn(
'transition-all duration-200',
!isCurrent && 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4'
)}
color={isCurrent ? 'default' : 'muted'}
href={resolvedHref}
size="m"
type="label"
onClick={onClick}
>
{children}
</Text>
</li>
@@ -90,6 +90,24 @@ export const LongWithScroll: StoryObj<typeof CodeBlock> = {
},
};
export const ShikiHighlight: StoryObj<typeof CodeBlock> = {
render: (args) => (
<CodeBlock {...args}>
<Code>
{`import React from 'react';
export const MyComponent = () => {
return <div>Hello World</div>;
};`}
</Code>
</CodeBlock>
),
args: {
label: 'ShikiHighlight.tsx',
language: 'tsx',
},
};
export const PlainText: StoryObj<typeof CodeBlock> = {
render: (args) => (
<CodeBlock {...args}>
+31 -9
View File
@@ -1,9 +1,19 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import React from 'react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { Code } from './Code';
import { CodeBlock } from './CodeBlock/CodeBlock';
// Mock shiki
vi.mock('shiki', () => ({
createHighlighter: vi.fn().mockResolvedValue({
codeToHtml: vi
.fn()
.mockReturnValue('<pre><code><span class="line">highlighted code</span></code></pre>'),
}),
}));
// Mock clipboard API
const mockWriteText = vi.fn().mockImplementation(() => Promise.resolve());
Object.assign(navigator, {
@@ -15,6 +25,7 @@ Object.assign(navigator, {
describe('Code Component', () => {
beforeEach(() => {
mockWriteText.mockClear();
vi.clearAllMocks();
});
it('renders inline code correctly', () => {
@@ -40,19 +51,18 @@ describe('Code Component', () => {
expect(screen.getByText('test.ts')).toBeInTheDocument();
});
it('copies content on click in inline mode', async () => {
it('copies content on click in inline mode', () => {
render(<Code copyValue="copied!">Display Text</Code>);
const codeElement = screen.getByText('Display Text');
await act(async () => {
act(() => {
fireEvent.click(codeElement);
await Promise.resolve();
});
expect(mockWriteText).toHaveBeenCalledWith('copied!');
});
it('copies content via button in block mode', async () => {
it('copies content via button in block mode', () => {
render(
<CodeBlock copyValue="block copy">
<Code>
@@ -62,23 +72,35 @@ describe('Code Component', () => {
);
const copyButton = screen.getByLabelText('Copy code');
await act(async () => {
act(() => {
fireEvent.click(copyButton);
await Promise.resolve();
});
expect(mockWriteText).toHaveBeenCalledWith('block copy');
});
it('extracts text from children if copyValue is not provided', async () => {
it('extracts text from children if copyValue is not provided', () => {
render(<Code>extracted</Code>);
const codeElement = screen.getByText('extracted');
await act(async () => {
act(() => {
fireEvent.click(codeElement);
await Promise.resolve();
});
expect(mockWriteText).toHaveBeenCalledWith('extracted');
});
it('uses shiki for highlighting when language is provided in block mode', async () => {
act(() => {
render(
<CodeBlock language="typescript">
<Code>const x: number = 1;</Code>
</CodeBlock>
);
});
// Wait for shiki async highlighting
const highlighted = await screen.findByText('highlighted code');
expect(highlighted).toBeInTheDocument();
});
});
+77 -8
View File
@@ -3,9 +3,13 @@ import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { usePress } from 'react-aria';
import { useCodeContext } from './contexts/CodeContext/CodeContext';
import { codeHighlighter } from './lib/highlighter/highlighter';
import type { CodeLanguage } from './lib/highlighter/highlighter';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
export type { CodeLanguage };
/**
* Props for the Code component.
*/
@@ -18,6 +22,12 @@ export interface CodeProps extends ClassNameProps, TestIdProps {
* If omitted in inline mode, the component will attempt to extract text from `children`.
*/
copyValue?: string;
/**
* The language of the code. Only used when rendered inside a CodeBlock.
* If provided, shiki will be used for syntax highlighting.
*/
language?: CodeLanguage;
}
/**
@@ -30,20 +40,28 @@ export interface CodeProps extends ClassNameProps, TestIdProps {
export const Code = memo(function Code({
children,
copyValue,
language: propsLanguage,
className,
'data-testid': testId,
}: CodeProps) {
const context = useCodeContext();
const [isCopied, setIsCopied] = useState(false);
const codeRef = useRef<HTMLElement>(null);
const divRef = useRef<HTMLDivElement>(null);
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
const language = propsLanguage ?? context?.language;
const handleCopy = useCallback(async () => {
try {
let textToCopy = copyValue;
if (!textToCopy && codeRef.current) {
textToCopy = codeRef.current.innerText;
if (!textToCopy) {
textToCopy = codeRef.current.textContent ?? undefined;
if (!textToCopy) {
const ref = codeRef.current ?? divRef.current;
if (ref) {
textToCopy = ref.innerText;
if (!textToCopy) {
textToCopy = ref.textContent ?? undefined;
}
}
}
@@ -67,6 +85,22 @@ export const Code = memo(function Code({
}
}, [isCopied]);
useEffect(() => {
if (context?.isInCodeBlock && language && typeof children === 'string') {
const highlight = async () => {
try {
const html = await codeHighlighter.highlight(children, language);
setHighlightedHtml(html);
} catch (err) {
console.error('Failed to highlight code:', err);
}
};
void highlight();
} else {
setHighlightedHtml(null);
}
}, [children, language, context?.isInCodeBlock]);
const { pressProps } = usePress({
onPress: onCopyPress,
});
@@ -74,7 +108,40 @@ export const Code = memo(function Code({
if (context?.isInCodeBlock) {
const { showLineNumbers } = context;
let content = children;
let content: React.ReactNode = children;
if (highlightedHtml) {
return (
<div
// 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
)}
data-testid={testId}
/>
);
}
if (typeof children === 'string') {
content = children
.trimEnd()
@@ -124,11 +191,13 @@ export const Code = memo(function Code({
aria-label="Copy code snippet"
className={cn(
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono',
'bg-[var(--rb-muted-base)]/50 text-[var(--rb-text)] border border-[var(--rb-outline)]/20',
'text-[var(--rb-color-red-600)] dark:text-[var(--rb-color-red-400)]',
'cursor-pointer transition-all duration-200',
'hover:bg-[var(--rb-muted-base)]/80',
'hover:text-[var(--rb-color-red-500)] hover:bg-[var(--rb-color-red-500)]/10',
'active:scale-95',
isCopied && 'ring-1 ring-[var(--rb-secondary-base)]',
isCopied
? 'bg-[var(--rb-color-red-500)]/20 ring-1 ring-[var(--rb-color-red-500)]/40'
: 'bg-transparent ring-1 ring-transparent',
className
)}
data-testid={testId}
@@ -6,6 +6,7 @@ import { Button } from '../../Button/Button';
import { Container } from '../../Container/Container';
import { CodeContext, type CodeContextValue } from '../contexts/CodeContext/CodeContext';
import type { CodeLanguage } from '../lib/highlighter/highlighter';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
@@ -21,6 +22,11 @@ export interface CodeBlockProps extends ClassNameProps, TestIdProps {
*/
label?: string;
/**
* The language of the code block.
*/
language?: CodeLanguage;
/**
* Whether to render line number gutters.
* Requires children (Code) to be rendered as block-level elements.
@@ -49,6 +55,7 @@ export interface CodeBlockProps extends ClassNameProps, TestIdProps {
export const CodeBlock = memo(function CodeBlock({
children,
label,
language,
showLineNumbers = true,
copyValue,
maxHeight,
@@ -92,8 +99,9 @@ export const CodeBlock = memo(function CodeBlock({
() => ({
isInCodeBlock: true,
showLineNumbers,
language,
}),
[showLineNumbers]
[showLineNumbers, language]
);
return (
@@ -1,5 +1,7 @@
import { createContext, useContext } from 'react';
import type { CodeLanguage } from '../../lib/highlighter/highlighter';
/**
* Value for the Code context.
*/
@@ -8,6 +10,8 @@ export interface CodeContextValue {
isInCodeBlock: boolean;
/** Whether to show line numbers in the block. */
showLineNumbers?: boolean;
/** The language of the code in the block. */
language?: CodeLanguage;
}
/**
@@ -0,0 +1,120 @@
import { createHighlighter } from 'shiki';
import type { Highlighter } from 'shiki';
/**
* Supported languages for syntax highlighting.
*/
export type CodeLanguage =
| 'typescript'
| 'javascript'
| 'tsx'
| 'jsx'
| 'bash'
| 'shell'
| 'json'
| 'css'
| 'html'
| 'markdown'
| 'yaml'
| 'dockerfile'
| 'toml'
| 'rust'
| 'go'
| 'python';
export const SUPPORTED_LANGUAGES: CodeLanguage[] = [
'typescript',
'javascript',
'tsx',
'jsx',
'bash',
'shell',
'json',
'css',
'html',
'markdown',
'yaml',
'dockerfile',
'toml',
'rust',
'go',
'python',
];
/**
* A singleton highlighter service for code blocks.
*/
class CodeHighlighter {
private static instance: CodeHighlighter | null = null;
private highlighter: Highlighter | null = null;
private initializingPromise: Promise<Highlighter> | null = null;
/**
* Gets the singleton instance of the highlighter.
* @example
* ```typescript
* const highlighter = CodeHighlighter.getInstance();
* ```
*/
public static getInstance(): CodeHighlighter {
return (CodeHighlighter.instance ??= new CodeHighlighter());
}
/**
* Initializes the highlighter if it hasn't been already.
* @example
* ```typescript
* const highlighter = await this.getHighlighter();
* ```
*/
private async getHighlighter(): Promise<Highlighter> {
if (this.highlighter) {
return this.highlighter;
}
if (this.initializingPromise) {
return this.initializingPromise;
}
this.initializingPromise = createHighlighter({
themes: ['github-dark', 'github-light'],
langs: SUPPORTED_LANGUAGES,
}).then((highlighter) => {
this.highlighter = highlighter;
this.initializingPromise = null;
return highlighter;
});
return this.initializingPromise;
}
/**
* Highlights the given code.
*
* @param code - The raw source code to highlight
* @param lang - The language of the code
* @returns A promise resolving to the highlighted HTML
* @example
* ```typescript
* const html = await highlighter.highlight('const a = 1;', 'typescript');
* ```
*/
public async highlight(code: string, lang: CodeLanguage): Promise<string> {
try {
const highlighter = await this.getHighlighter();
return highlighter.codeToHtml(code, {
lang,
themes: {
light: 'github-light',
dark: 'github-dark',
},
});
} catch (err) {
console.error('Failed to highlight code:', err);
throw err;
}
}
}
export const codeHighlighter = CodeHighlighter.getInstance();
@@ -26,6 +26,9 @@ const meta: Meta<typeof Container> = {
control: 'select',
options: ['none', 'sm', 'md', 'lg', 'full'],
},
href: {
control: 'text',
},
},
};
@@ -45,6 +48,14 @@ export const Default: Story = {
},
};
export const AsLink: Story = {
args: {
...Default.args,
href: '#',
children: 'This container is a link.',
},
};
export const SemanticColors: Story = {
render: () => (
<VStack gap={4}>
@@ -15,7 +15,7 @@ describe('components/Container', () => {
expect(handlePress).toHaveBeenCalledTimes(1);
});
it('applies cursor-pointer and active styles when onPress is present', () => {
it('applies interactive styles when onPress is present', () => {
const { rerender } = render(<Container>Content</Container>);
let container = screen.getByText('Content');
expect(container).not.toHaveClass('cursor-pointer');
@@ -23,6 +23,7 @@ describe('components/Container', () => {
rerender(<Container onPress={() => {}}>Content</Container>);
container = screen.getByText('Content');
expect(container).toHaveClass('cursor-pointer');
expect(container).toHaveClass('hover:brightness-95');
expect(container).toHaveClass('active:opacity-80');
});
@@ -36,4 +37,38 @@ describe('components/Container', () => {
rerender(<Container as="section">Section Content</Container>);
expect(screen.getByText('Section Content').tagName).toBe('SECTION');
});
it('renders as an anchor tag with href when href prop is provided', () => {
render(<Container href="/test-path">Link Content</Container>);
const linkElement = screen.getByText('Link Content');
expect(linkElement.tagName).toBe('A');
expect(linkElement).toHaveAttribute('href', '/test-path');
});
it('gives precedence to href over onPress', async () => {
const user = userEvent.setup();
const handlePress = vi.fn();
render(
<Container href="/test-path" onPress={handlePress}>
Link Content
</Container>
);
const linkElement = screen.getByText('Link Content');
await user.click(linkElement);
expect(handlePress).not.toHaveBeenCalled();
expect(linkElement.tagName).toBe('A');
});
it('warns when both href and onPress are provided', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(
<Container href="/test-path" onPress={() => {}}>
Content
</Container>
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.'
);
consoleWarnSpy.mockRestore();
});
});
@@ -84,9 +84,17 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
shadow?: boolean;
/** Whether to show a border */
border?: boolean;
/** Press interaction handler */
/**
* Press interaction handler.
* Cannot be used with `href`.
*/
onPress?: PressEvents['onPress'];
/** The HTML element to use for rendering */
/**
* If provided, the container will be rendered as an `<a>` tag.
* Cannot be used with `onPress`.
*/
href?: string;
/** The HTML element to use for rendering. Overridden to `a` if `href` is provided. */
as?: React.ElementType;
}
@@ -105,17 +113,26 @@ export const Container = memo(function Container({
shadow = false,
border = false,
onPress,
href,
as: Component = 'div',
className,
'data-testid': testId,
}: ContainerProps) {
if (process.env.NODE_ENV !== 'production' && onPress && href) {
console.warn('[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.');
}
const isLink = href != null;
const isPressable = !!onPress && !isLink;
const ref = React.useRef<HTMLElement>(null);
const { pressProps } = usePress({
onPress,
isDisabled: !onPress,
isDisabled: !isPressable,
});
const interactionProps = onPress ? pressProps : {};
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)]',
@@ -181,7 +198,7 @@ export const Container = memo(function Container({
};
return (
<Component
<FinalComponent
{...interactionProps}
ref={ref}
className={cn(
@@ -192,12 +209,14 @@ export const Container = memo(function Container({
paddingClasses[padding],
shadow && 'shadow-md',
border && 'border border-[var(--rb-outline)]',
onPress && 'cursor-pointer active:opacity-80 transition-opacity',
(isPressable || isLink) &&
'cursor-pointer transition-all duration-200 hover:brightness-95 active:opacity-80',
className
)}
data-testid={testId}
href={href}
>
{children}
</Component>
</FinalComponent>
);
});
+29 -3
View File
@@ -1,6 +1,8 @@
import React, { memo } from 'react';
import { Separator } from '../Separator/Separator';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { TextProps, TextType, TextSize } from '../Text/Text';
@@ -19,7 +21,14 @@ export interface HeaderProps extends Omit<TextProps, 'as'> {
* typography variants in the design system, ensuring accessibility
* and visual consistency.
*/
export const Header = memo(function Header({ level, type, size, children, ...props }: HeaderProps) {
export const Header = memo(function Header({
level,
type,
size,
children,
className,
...props
}: HeaderProps) {
// Default mappings from level to type and size
const levelMapping: Record<number, { type: TextType; size: TextSize }> = {
1: { type: 'display', size: 'l' },
@@ -41,9 +50,26 @@ export const Header = memo(function Header({ level, type, size, children, ...pro
} as const;
const Tag = tags[level];
return (
<Text as={Tag} size={size ?? defaultStyles.size} type={type ?? defaultStyles.type} {...props}>
const content = (
<Text
as={Tag}
className={className}
size={size ?? defaultStyles.size}
type={type ?? defaultStyles.type}
{...props}
>
{children}
</Text>
);
if (level <= 2) {
return (
<VStack className="w-fit" gap={0.5}>
{content}
<Separator thinned="thinned-end" />
</VStack>
);
}
return content;
});
@@ -1,9 +1,23 @@
import React from 'react';
import { vi } from 'vitest';
import { Link } from './Link';
import { render, screen } from '../../lib/testing';
describe('components/Link', () => {
beforeEach(() => {
// Mock IntersectionObserver to avoid errors in Text component
class MockIntersectionObserver {
observe = vi.fn();
disconnect = vi.fn();
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders correctly as a link when href is provided', () => {
render(<Link href="https://example.com">Click me</Link>);
const link = screen.getByRole('link', { name: 'Click me' });
@@ -44,4 +58,14 @@ describe('components/Link', () => {
render(<Link onPress={() => {}}>Click me</Link>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('passes delay prop to the underlying Text component', () => {
render(
<Link delay={500} href="/test">
Delayed Link
</Link>
);
const link = screen.getByRole('link');
expect(link).toHaveClass('opacity-0');
});
});
+9 -3
View File
@@ -1,3 +1,4 @@
import { localePath } from '@ermnvldmr/i18n';
import { castRef, cn } from '@ermnvldmr/stl';
import React, { memo, useRef } from 'react';
import { useLink } from 'react-aria';
@@ -24,6 +25,8 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
size?: TextSize;
/** Text color variant */
color?: TextColor;
/** Delay in milliseconds before the link appears after entering the viewport. */
delay?: number;
}
/**
@@ -33,7 +36,7 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
*/
export const Link = memo(function Link(props: LinkProps) {
const { children, className, href, isExternal, type, size, color, ...otherProps } = props;
const { children, className, href, isExternal, type, size, color, delay, ...otherProps } = props;
// We use a specific ref type that useLink expects.
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
@@ -42,12 +45,13 @@ export const Link = memo(function Link(props: LinkProps) {
const sharedClasses = cn(
!color && 'text-[var(--rb-ring)]',
'cursor-pointer hover:underline underline-offset-4 transition-colors',
'cursor-pointer underline hover:no-underline underline-offset-4 transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
);
if (href) {
const resolvedHref = localePath(href);
const isAutoExternal = isExternal ?? href.startsWith('http');
const externalProps = isAutoExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {};
@@ -59,7 +63,8 @@ export const Link = memo(function Link(props: LinkProps) {
as="a"
className={sharedClasses}
color={color}
href={href}
delay={delay}
href={resolvedHref}
size={size}
type={type}
>
@@ -75,6 +80,7 @@ export const Link = memo(function Link(props: LinkProps) {
as="span"
className={sharedClasses}
color={color}
delay={delay}
size={size}
type={type}
>
@@ -161,3 +161,66 @@ export const ListStyles: Story = {
</div>
),
};
/**
* Showcases code blocks with language-specific syntax highlighting.
*/
export const CodeLanguages: Story = {
render: () => (
<div className="max-w-[800px] mx-auto space-y-8">
<div>
<Markdown.H3 className="mb-4">TypeScript</Markdown.H3>
<Markdown.Pre label="example.ts">
<Markdown.Code className="language-typescript">
{`interface User {
id: number;
name: string;
}
const greet = (user: User) => {
console.log(\`Hello, \${user.name}!\`);
};`}
</Markdown.Code>
</Markdown.Pre>
</div>
<div>
<Markdown.H3 className="mb-4">Rust</Markdown.H3>
<Markdown.Pre label="main.rs">
<Markdown.Code className="language-rust">
{`fn main() {
let name = "World";
println!("Hello, {}!", name);
}`}
</Markdown.Code>
</Markdown.Pre>
</div>
<div>
<Markdown.H3 className="mb-4">Python</Markdown.H3>
<Markdown.Pre label="script.py">
<Markdown.Code className="language-python">
{`def greet(name: str):
print(f"Hello, {name}!")
if __name__ == "__main__":
greet("World")`}
</Markdown.Code>
</Markdown.Pre>
</div>
<div>
<Markdown.H3 className="mb-4">JSON</Markdown.H3>
<Markdown.Pre label="data.json">
<Markdown.Code className="language-json">
{`{
"project": "ermnvldmr.com",
"version": "1.0.0",
"private": true
}`}
</Markdown.Code>
</Markdown.Pre>
</div>
</div>
),
};
@@ -29,7 +29,7 @@ describe('Markdown Components', () => {
);
expect(screen.getByText('Heading 1')).toBeInTheDocument();
expect(screen.getByText('This is a paragraph.')).toBeInTheDocument();
expect(screen.getByText('This is a paragraph.')).toHaveClass('mb-4');
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com');
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
@@ -1,12 +1,43 @@
import React, { memo } from 'react';
import React, { memo, useMemo } from 'react';
import { Code } from '../../Code/Code';
import { SUPPORTED_LANGUAGES } from '../../Code/lib/highlighter/highlighter';
import type { CodeProps } from '../../Code/Code';
import type { CodeLanguage } from '../../Code/lib/highlighter/highlighter';
/**
* Checks if a string is a valid code language.
* @param lang The string to check.
* @returns True if the string is a valid code language.
* @example
* ```typescript
* isCodeLanguage('typescript'); // true
* isCodeLanguage('foo'); // false
* ```
*/
function isCodeLanguage(lang: string): lang is CodeLanguage {
return SUPPORTED_LANGUAGES.some((l) => l === lang);
}
/**
* A Markdown code component for inline snippets.
*/
export const MarkdownCode = memo(function MarkdownCode(props: CodeProps) {
return <Code {...props} />;
const { className, ...rest } = props;
const language = useMemo(() => {
if (className) {
const match = /language-(\w+)/.exec(className);
if (match) {
const lang = match[1];
if (isCodeLanguage(lang)) {
return lang;
}
}
}
return undefined;
}, [className]);
return <Code language={language} {...rest} />;
});
@@ -1,12 +1,17 @@
import React, { memo } from 'react';
import { List } from '../../List/List';
import { MarkdownP } from '../MarkdownP/MarkdownP';
import type { ListItemProps } from '../../List/List';
/**
* A Markdown list item component.
*/
export const MarkdownLI = memo(function MarkdownLI(props: ListItemProps) {
return <List.Item {...props} />;
export const MarkdownLI = memo(function MarkdownLI({ children, ...props }: ListItemProps) {
return (
<List.Item {...props}>
<MarkdownP>{children}</MarkdownP>
</List.Item>
);
});
@@ -1,8 +1,24 @@
import React, { memo } from 'react';
import React, { memo, useMemo } from 'react';
import { CodeBlock } from '../../Code/CodeBlock/CodeBlock';
import { SUPPORTED_LANGUAGES } from '../../Code/lib/highlighter/highlighter';
import type { CodeBlockProps } from '../../Code/CodeBlock/CodeBlock';
import type { CodeLanguage } from '../../Code/lib/highlighter/highlighter';
/**
* Checks if a string is a valid code language.
* @param lang The string to check.
* @returns True if the string is a valid code language.
* @example
* ```typescript
* isCodeLanguage('typescript'); // true
* isCodeLanguage('foo'); // false
* ```
*/
function isCodeLanguage(lang: string): lang is CodeLanguage {
return SUPPORTED_LANGUAGES.some((l) => l === lang);
}
/**
* A Markdown pre component for code blocks.
@@ -10,5 +26,27 @@ import type { CodeBlockProps } from '../../Code/CodeBlock/CodeBlock';
* Wraps content in the design system CodeBlock.
*/
export const MarkdownPre = memo(function MarkdownPre(props: CodeBlockProps) {
return <CodeBlock {...props} />;
const { children, ...rest } = props;
// MDX often passes a <code> element as the only child of <pre>.
// That <code> element usually has the language class (e.g., "language-typescript").
const language = useMemo(() => {
if (React.isValidElement<{ className?: string }>(children) && children.props.className) {
const className = children.props.className;
const match = /language-(\w+)/.exec(className);
if (match) {
const lang = match[1];
if (isCodeLanguage(lang)) {
return lang;
}
}
}
return undefined;
}, [children]);
return (
<CodeBlock language={language} {...rest}>
{children}
</CodeBlock>
);
});
@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, it, expect } from 'vitest';
import { PageColumn } from './PageColumn';
describe('PageColumn', () => {
it('renders children correctly', () => {
render(
<PageColumn>
<div data-testid="child">Test Content</div>
</PageColumn>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
});
it('applies size classes', () => {
const { container: small } = render(<PageColumn size="small">Content</PageColumn>);
expect(small.firstChild).toHaveClass('lg:w-[var(--rb-page-column-small,300px)]');
const { container: full } = render(<PageColumn size="full">Content</PageColumn>);
expect(full.firstChild).toHaveClass('flex-1');
});
it('handles sticky prop', () => {
const { container } = render(<PageColumn sticky>Content</PageColumn>);
expect(container.firstChild).toHaveClass('lg:sticky');
expect(container.firstChild).toHaveClass('lg:top-24');
});
it('renders as different element', () => {
render(<PageColumn as="aside">Content</PageColumn>);
expect(screen.getByRole('complementary')).toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
/**
* Props for the PageColumn component.
*/
export interface PageColumnProps {
/** The content of the column. */
children: React.ReactNode;
/**
* The size of the column on desktop.
* - `small`: Fixed width (300px)
* - `full`: Takes up remaining space (flex-1)
* @default 'full'
*/
size?: 'small' | 'full';
/**
* If true, the column will stick to the top when scrolling on desktop.
* @default false
*/
sticky?: boolean;
/**
* Optional additional class names.
*/
className?: string;
/**
* The HTML element to use for the root of the component.
* @default 'div'
*/
as?: React.ElementType;
}
/**
/**
* A single column unit within the PageColumns host.
*
* @param props - The component props.
* @returns A PageColumn component.
* @example
* ```tsx
* <PageColumn size="small">Sidebar</PageColumn>
* ```
*/
export const PageColumn = ({
children,
size = 'full',
sticky = false,
className,
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>
);
};
@@ -0,0 +1,137 @@
import React from 'react';
import { PageColumns } from './PageColumns';
import { PageColumn } from '../PageColumn/PageColumn';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PageColumns> = {
title: 'components/Page/PageColumns',
component: PageColumns,
parameters: {
layout: 'padded',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof PageColumns>;
const Box = ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div
className={`p-6 bg-slate-100 dark:bg-slate-900 border-2 border-slate-300 dark:border-slate-700 rounded-xl h-full flex items-center justify-center font-bold text-center ${className}`}
>
{children}
</div>
);
/**
* A standard two-column layout: small sidebar and full main area.
*/
export const TwoColumnsSidebarLeft: Story = {
args: {
gap: 8,
children: (
<>
<PageColumn size="small">
<Box className="bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
Small Sidebar
</Box>
</PageColumn>
<PageColumn size="full">
<Box className="min-h-[200px]">Main Full Content</Box>
</PageColumn>
</>
),
},
};
/**
* A standard two-column layout with sidebar on the right.
*/
export const TwoColumnsSidebarRight: Story = {
args: {
gap: 8,
children: (
<>
<PageColumn size="full">
<Box className="min-h-[200px]">Main Full Content</Box>
</PageColumn>
<PageColumn size="small">
<Box className="bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
Small Sidebar
</Box>
</PageColumn>
</>
),
},
};
/**
* Three columns: small sidebar on both sides.
*/
export const ThreeColumns: Story = {
args: {
gap: 6,
children: (
<>
<PageColumn size="small">
<Box className="bg-blue-50 dark:bg-blue-950 border-blue-200">Left Sidebar</Box>
</PageColumn>
<PageColumn size="full">
<Box className="min-h-[200px]">Primary Content</Box>
</PageColumn>
<PageColumn size="small">
<Box className="bg-blue-50 dark:bg-blue-950 border-blue-200">Right Sidebar</Box>
</PageColumn>
</>
),
},
};
/**
* Two full-width columns sharing space equally.
*/
export const TwoFullColumns: Story = {
args: {
gap: 8,
children: (
<>
<PageColumn size="full">
<Box className="min-h-[200px]">Column A (full)</Box>
</PageColumn>
<PageColumn size="full">
<Box className="min-h-[200px]">Column B (full)</Box>
</PageColumn>
</>
),
},
};
/**
* A sidebar that sticks to the top when scrolling.
*/
export const StickySidebar: Story = {
render: (args) => (
<div className="h-[800px] overflow-y-auto border-2 border-dashed p-4 rounded-lg">
<p className="mb-4 text-center italic text-slate-500">Scroll down to see the sidebar stick</p>
<PageColumns {...args} gap={8}>
<PageColumn size="full">
<div className="flex flex-col gap-4">
{Array.from({ length: 15 }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<Box key={i} className="min-h-[100px]">
Main Content Block {i + 1}
</Box>
))}
</div>
</PageColumn>
<PageColumn sticky size="small">
<Box className="bg-yellow-50 dark:bg-yellow-950 border-yellow-200 min-h-[300px]">
I am a Sticky Sidebar!
</Box>
</PageColumn>
</PageColumns>
</div>
),
};
@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, it, expect } from 'vitest';
import { PageColumns } from './PageColumns';
describe('PageColumns', () => {
it('renders children correctly', () => {
render(
<PageColumns>
<div data-testid="child">Test Content</div>
</PageColumns>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
});
it('applies gap class', () => {
const { container: gap8 } = render(<PageColumns gap={8}>Content</PageColumns>);
expect(gap8.firstChild).toHaveClass('gap-8');
const { container: gapCustom } = render(<PageColumns gap="gap-[20px]">Content</PageColumns>);
expect(gapCustom.firstChild).toHaveClass('gap-[20px]');
});
it('has base responsive classes', () => {
const { container } = render(<PageColumns>Content</PageColumns>);
expect(container.firstChild).toHaveClass('flex');
expect(container.firstChild).toHaveClass('flex-col');
expect(container.firstChild).toHaveClass('lg:flex-row');
});
});
@@ -0,0 +1,51 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
/**
* Props for the PageColumns component.
*/
export interface PageColumnsProps {
/** The columns to be displayed within the grid. */
children: React.ReactNode;
/**
* Spacing between columns.
* @default 6
*/
gap?: number | string;
/**
* Optional additional class names.
*/
className?: string;
}
/**
/**
* Host component for a responsive column system.
* It handles the flex/grid layout for page columns.
*
* @param props - The component props.
* @returns A PageColumns component.
* @example
* ```tsx
* <PageColumns gap={8}>
* <PageColumn size="small">Sidebar</PageColumn>
* <PageColumn size="full">Main Content</PageColumn>
* </PageColumns>
* ```
*/
export const PageColumns = ({ children, gap = 6, className }: PageColumnsProps) => {
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>
);
};
@@ -0,0 +1,120 @@
import React from 'react';
import { PageContainer } from './PageContainer';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PageContainer> = {
title: 'components/Page/PageContainer',
component: PageContainer,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
argTypes: {
width: {
control: 'select',
options: ['slim', 'default', 'wide', 'full'],
},
paddingX: {
control: 'select',
options: ['none', 'small', 'medium', 'large'],
},
paddingY: {
control: 'select',
options: ['none', 'small', 'medium', 'large'],
},
centerVertically: {
control: 'boolean',
},
},
};
export default meta;
type Story = StoryObj<typeof PageContainer>;
const Box = ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div
className={`p-4 bg-slate-200 dark:bg-slate-800 border-2 border-slate-400 dark:border-slate-600 rounded-lg ${className}`}
>
{children}
</div>
);
/**
* Standard container with default width and padding.
*/
export const Default: Story = {
args: {
children: <Box>Default Container Content</Box>,
width: 'default',
paddingY: 'medium',
},
};
/**
* Narrower slim container, ideal for articles.
*/
export const Slim: Story = {
args: {
children: <Box>Slim Container Content (1100px Max-Width)</Box>,
width: 'slim',
paddingY: 'medium',
},
};
/**
* Wider wide container, useful for dashboards.
*/
export const Wide: Story = {
args: {
children: <Box>Wide Container Content (1920px Max-Width)</Box>,
width: 'wide',
paddingY: 'medium',
},
};
/**
* Vertically centered container.
*/
export const VerticallyCentered: Story = {
render: (args) => (
<div className="h-[400px] border-2 border-dashed border-slate-300 dark:border-slate-700 m-4 flex flex-col">
<PageContainer {...args} centerVertically className="flex-1">
<Box className="text-center font-bold">I am vertically centered!</Box>
</PageContainer>
</div>
),
};
/**
* Demonstration of different horizontal padding scales.
*/
export const PaddingScales: Story = {
render: () => (
<div className="flex flex-col gap-8 py-8">
<div>
<h3 className="mb-2 font-bold px-4">paddingX: small</h3>
<PageContainer paddingX="small" width="full">
<Box className="bg-blue-100 dark:bg-blue-900 border-blue-300">Small Padding Content</Box>
</PageContainer>
</div>
<div>
<h3 className="mb-2 font-bold px-4">paddingX: medium (Default)</h3>
<PageContainer paddingX="medium" width="full">
<Box className="bg-green-100 dark:bg-green-900 border-green-300">
Medium Padding Content
</Box>
</PageContainer>
</div>
<div>
<h3 className="mb-2 font-bold px-4">paddingX: large</h3>
<PageContainer paddingX="large" width="full">
<Box className="bg-purple-100 dark:bg-purple-900 border-purple-300">
Large Padding Content
</Box>
</PageContainer>
</div>
</div>
),
};
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, it, expect } from 'vitest';
import { PageContainer } from './PageContainer';
describe('PageContainer', () => {
it('renders children correctly', () => {
render(
<PageContainer>
<div data-testid="child">Test Content</div>
</PageContainer>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
});
it('applies correct width classes', () => {
const { container: slim } = render(<PageContainer width="slim">Content</PageContainer>);
expect(slim.firstChild).toHaveClass('max-w-[var(--rb-page-width-slim,1100px)]');
const { container: def } = render(<PageContainer width="default">Content</PageContainer>);
expect(def.firstChild).toHaveClass('max-w-[var(--rb-page-width-default,1600px)]');
const { container: wide } = render(<PageContainer width="wide">Content</PageContainer>);
expect(wide.firstChild).toHaveClass('max-w-[var(--rb-page-width-wide,1920px)]');
const { container: full } = render(<PageContainer width="full">Content</PageContainer>);
expect(full.firstChild).toHaveClass('max-w-full');
});
it('applies padding classes', () => {
const { container: small } = render(
<PageContainer paddingX="small" paddingY="small">
Content
</PageContainer>
);
expect(small.firstChild).toHaveClass('px-2');
expect(small.firstChild).toHaveClass('py-4');
const { container: large } = render(
<PageContainer paddingX="large" paddingY="large">
Content
</PageContainer>
);
expect(large.firstChild).toHaveClass('px-6');
expect(large.firstChild).toHaveClass('py-12');
});
it('handles centerVertically prop', () => {
const { container } = render(<PageContainer centerVertically>Content</PageContainer>);
expect(container.firstChild).toHaveClass('flex-1');
expect(container.firstChild).toHaveClass('flex-col');
expect(container.firstChild).toHaveClass('justify-center');
});
it('renders as different element', () => {
render(<PageContainer as="main">Content</PageContainer>);
expect(screen.getByRole('main')).toBeInTheDocument();
});
});
@@ -0,0 +1,119 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
/**
* Supported width configurations for the PageContainer.
* - `slim`: 1100px
* - `default`: 1600px
* - `wide`: 1920px
* - `full`: 100%
*/
export type PageWidth = 'slim' | 'default' | 'wide' | 'full';
/**
* Supported padding scales for the PageContainer.
* - `none`: No padding.
* - `small`: Standard small padding.
* - `medium`: Standard medium padding.
* - `large`: Standard large padding.
*/
export type PagePadding = 'none' | 'small' | 'medium' | 'large';
/**
* Props for the PageContainer.
*/
export interface PageContainerProps {
/** The content of the container. */
children: React.ReactNode;
/**
* The maximum width of the container on desktop.
* @default 'default'
*/
width?: PageWidth;
/**
* Horizontal padding scale.
* @default 'medium'
*/
paddingX?: PagePadding;
/**
* Vertical padding scale.
* @default 'none'
*/
paddingY?: PagePadding;
/**
* If true, vertically centers the content on the page.
* Has no effect if the content is taller than the viewport.
* @default false
*/
centerVertically?: boolean;
/**
* Optional additional class names.
*/
className?: string;
/**
* The HTML element to use for the root of the component.
* @default 'div'
*/
as?: React.ElementType;
}
/**
/**
* Manages the horizontal and vertical constraints for page content.
*
* @param props - The component props.
* @returns A PageContainer component.
* @example
* ```tsx
* <PageContainer width="slim" paddingY="medium">
* Main Content
* </PageContainer>
* ```
*/
export const PageContainer = ({
children,
width = 'default',
paddingX = 'medium',
paddingY = 'none',
centerVertically = false,
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,
},
className
)}
>
{children}
</Component>
);
};
@@ -0,0 +1,102 @@
import React from 'react';
import { PageHead } from './PageHead';
import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs';
import { Button } from '../../Button/Button';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PageHead> = {
title: 'Components/PageHead',
component: PageHead,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof PageHead>;
const dummyParagraphs = Array.from({ length: 50 }).map((_, i) => `paragraph-${i}`);
const DummyContent = () => (
<div className="p-8">
{dummyParagraphs.map((key) => (
<p key={key}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
))}
</div>
);
export const Default: Story = {
args: {
heading: 'Page Title',
caption: 'A short and inspiring caption for the page.',
breadcrumbs: (
<Breadcrumbs>
<Breadcrumbs.Item>
<a href="/">Home</a>
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>Components</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>PageHead</Breadcrumbs.Item>
</Breadcrumbs>
),
addonLeft: <Button variant="outline">Back</Button>,
addonRight: <Button>Primary Action</Button>,
},
render: (args) => (
<div>
<PageHead {...args} />
<DummyContent />
</div>
),
};
export const CollapsibleSticky: Story = {
...Default,
args: {
...Default.args,
strategy: 'collapsible-sticky',
},
};
export const AlwaysExpandedFixed: Story = {
...Default,
args: {
...Default.args,
strategy: 'always-expanded-fixed',
},
};
export const AlwaysCollapsedSticky: Story = {
...Default,
args: {
...Default.args,
strategy: 'always-collapsed-sticky',
},
};
export const WithoutAddons: Story = {
...Default,
args: {
...Default.args,
addonLeft: undefined,
addonRight: undefined,
},
};
export const WithoutBreadcrumbs: Story = {
...Default,
args: {
...Default.args,
breadcrumbs: undefined,
},
};
@@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { PageHead } from './PageHead';
describe('PageHead', () => {
it('renders the heading and caption', () => {
render(<PageHead caption="Test Caption" heading="Test Heading" />);
// Both layouts are rendered (one hidden), so we use getAllByText
expect(screen.getAllByText('Test Heading')[0]).toBeInTheDocument();
expect(screen.getByText('Test Caption')).toBeInTheDocument();
});
it('renders breadcrumbs when provided', () => {
render(<PageHead breadcrumbs={<div>My Breadcrumbs</div>} heading="Test" />);
expect(screen.getByText('My Breadcrumbs')).toBeInTheDocument();
});
it('renders addons when provided', () => {
render(
<PageHead
addonLeft={<div>Left Addon</div>}
addonRight={<div>Right Addon</div>}
heading="Test"
/>
);
// Addons are rendered in both layouts
expect(screen.getAllByText('Left Addon')[0]).toBeInTheDocument();
expect(screen.getAllByText('Right Addon')[0]).toBeInTheDocument();
});
it('applies the correct classes for the strategy', () => {
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
const header = screen.getByRole('banner');
expect(header).toHaveClass('fixed');
expect(header).toHaveClass('shadow-md');
});
});
@@ -0,0 +1,151 @@
import { cn, useScroll, useResizeObserver } from '@ermnvldmr/stl';
import React, { type ReactNode, useRef, useEffect, useState } from 'react';
import { Header } from '../../Header/Header';
import { HStack } from '../../HStack/HStack';
import { Text } from '../../Text/Text';
import { VStack } from '../../VStack/VStack';
import { PageContainer } from '../PageContainer/PageContainer';
/**
* Defines the positioning and behavior of the PageHead component.
* - `always-collapsed-fixed`: Always collapsed and fixed to the top.
* - `always-collapsed-sticky`: Always collapsed and sticks to the top when scrolling.
* - `always-expanded-fixed`: Always expanded and fixed to the top.
* - `always-expanded-sticky`: Always expanded and sticks to the top when scrolling.
* - `collapsible-sticky`: Starts expanded and collapses when scrolling down, sticking to the top.
*/
export type PageHeadStrategy =
| 'always-collapsed-fixed'
| 'always-collapsed-sticky'
| 'always-expanded-fixed'
| 'always-expanded-sticky'
| 'collapsible-sticky';
/**
* Props for the PageHead component.
*/
export interface PageHeadProps {
/** The main heading text. */
heading: string;
/** The caption text displayed below the heading. */
caption?: ReactNode;
/** The breadcrumbs navigation component. */
breadcrumbs?: ReactNode;
/** A component to be displayed on the left side of the header. */
addonLeft?: ReactNode;
/** A component to be displayed on the right side of the header. */
addonRight?: ReactNode;
/** The positioning and behavior strategy for the header. */
strategy?: PageHeadStrategy;
/** Additional CSS classes for the component. */
className?: string;
}
/**
* A universal page head component with multiple layout and behavior strategies.
*
* @param props - The props for the component.
* @returns A PageHead component.
*
* @example
* ```tsx
* <PageHead
* heading="Page Title"
* caption="A short and inspiring caption"
* strategy="collapsible-sticky"
* />
* ```
*/
export const PageHead = ({
heading,
caption,
breadcrumbs,
addonLeft,
addonRight,
strategy = 'collapsible-sticky',
className,
}: PageHeadProps) => {
const { y: scrollY } = useScroll();
const [isCollapsed, setIsCollapsed] = useState(strategy.includes('always-collapsed'));
const headerRef = useRef<HTMLDivElement>(null);
const { height } = useResizeObserver(headerRef);
useEffect(() => {
if (strategy === 'collapsible-sticky') {
setIsCollapsed(scrollY > 10);
}
}, [scrollY, strategy]);
const rootClasses = cn(
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] fixed top-0 z-10 border-b border-[var(--rb-outline)]/10',
{
'shadow-md bg-[var(--rb-background)]/90 backdrop-blur-md': isCollapsed,
},
className
);
const expandedContent = (
<PageContainer paddingY="medium">
<VStack align="stretch" gap={6}>
<HStack align="center" justify="between">
<HStack align="center" gap={4}>
{addonLeft && <div>{addonLeft}</div>}
{breadcrumbs && <div className="hidden sm:block">{breadcrumbs}</div>}
</HStack>
{addonRight && <div>{addonRight}</div>}
</HStack>
<VStack gap={2}>
<Header level={1}>{heading}</Header>
{typeof caption === 'string' ? (
<Text color="muted" size="l" type="body">
{caption}
</Text>
) : (
caption
)}
</VStack>
</VStack>
</PageContainer>
);
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">
{heading}
</Header>
</HStack>
{addonRight && <div className="flex-shrink-0 ml-4">{addonRight}</div>}
</HStack>
</PageContainer>
);
return (
<>
<header ref={headerRef} className={rootClasses}>
<div
className={cn(
'grid transition-[grid-template-rows] duration-300 ease-in-out',
isCollapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'
)}
inert={isCollapsed || undefined}
>
<div className="overflow-hidden">{expandedContent}</div>
</div>
<div
className={cn(
'grid transition-[grid-template-rows] duration-300 ease-in-out',
isCollapsed ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
inert={!isCollapsed || undefined}
>
<div className="overflow-hidden">{collapsedContent}</div>
</div>
</header>
<div style={{ height }} />
</>
);
};
@@ -0,0 +1,51 @@
import React from 'react';
import { PageRoot } from './PageRoot';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PageRoot> = {
title: 'components/Page/PageRoot',
component: PageRoot,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof PageRoot>;
/**
* The default PageRoot story showing the base technical container.
*/
export const Default: Story = {
args: {
children: (
<div className="p-8">
<h1 className="text-2xl font-bold">Page Root Content</h1>
<p className="mt-4">
This is the base technical container that handles theme initialization and core
background/text colors.
</p>
</div>
),
},
};
/**
* PageRoot with custom background classes.
*/
export const CustomStyling: Story = {
args: {
className: 'bg-slate-100 dark:bg-slate-900',
children: (
<div className="p-8">
<h1 className="text-2xl font-bold">Custom Styled Root</h1>
<p className="mt-4 text-slate-600 dark:text-slate-400">
PageRoot accepts a className prop to override or extend base styles.
</p>
</div>
),
},
};
@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, it, expect } from 'vitest';
import { PageRoot } from './PageRoot';
describe('PageRoot', () => {
it('renders children correctly', () => {
render(
<PageRoot>
<div data-testid="child">Test Content</div>
</PageRoot>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(
<PageRoot className="custom-class">
<div>Content</div>
</PageRoot>
);
expect(container.firstChild).toHaveClass('custom-class');
});
it('has base technical classes', () => {
const { container } = render(
<PageRoot>
<div>Content</div>
</PageRoot>
);
expect(container.firstChild).toHaveClass('w-full');
});
});
@@ -0,0 +1,34 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { useTheme } from '../../../lib/theme';
/**
* Props for the PageRoot component.
*/
export interface PageRootProps {
/** The main content slot. */
children: React.ReactNode;
/** Additional CSS classes for the root container. */
className?: string;
}
/**
/**
* The technical bootstrap layer for all layouts.
* It handles theme initialization and base background/text colors.
*
* @param props - The component props.
* @returns A PageRoot component.
* @example
* ```tsx
* <PageRoot>
* <PageContainer>Content</PageContainer>
* </PageRoot>
* ```
*/
export const PageRoot = ({ children, className }: PageRootProps) => {
useTheme();
return <div className={cn('w-full transition-colors duration-200', className)}>{children}</div>;
};
+5
View File
@@ -0,0 +1,5 @@
export * from './PageRoot/PageRoot';
export * from './PageContainer/PageContainer';
export * from './PageColumns/PageColumns';
export * from './PageColumn/PageColumn';
export * from './PageHead/PageHead';
@@ -11,8 +11,8 @@ import type { Meta, StoryObj } from '@storybook/react';
* - Single or double line variants inspired by newspaper layout traditions
* - Multiple color options (black, outline, outline-light) with theme support
* - Horizontal and vertical orientations
* - Optional thinned gradient fade effect at line ends (10% fade length)
* - 2px stroke width following print design standards
* - Optional thinned gradient fade effect at line ends ('thinned', 'thinned-start', 'thinned-end', 'none')
* - 1px stroke width following print design standards
* - Semantic div elements with proper ARIA separator role for accessibility
*/
const meta: Meta<typeof Separator> = {
@@ -44,14 +44,15 @@ const meta: Meta<typeof Separator> = {
description: 'Orientation of the separator',
},
thinned: {
control: 'boolean',
description: 'Whether line ends should be thinned with gradient fade (10% fade length)',
control: 'select',
options: ['thinned', 'thinned-start', 'thinned-end', 'none'],
description: 'How line ends should be thinned with gradient fade',
},
},
args: {
type: 'single',
direction: 'horizontal',
thinned: true,
thinned: 'thinned',
},
};
@@ -63,7 +64,7 @@ type Story = StoryObj<typeof meta>;
/**
* The default single-line separator with light outline color.
* Uses the newspaper-standard 2px stroke with thinned gradient ends.
* Uses the newspaper-standard 1px stroke with thinned gradient ends.
*/
export const Default: Story = {
args: {},
@@ -191,7 +192,7 @@ export const VerticalDouble: Story = {
*/
export const NonThinned: Story = {
args: {
thinned: false,
thinned: 'none',
},
decorators: [
(Story) => (
@@ -251,19 +252,27 @@ export const AllTypes: Story = {
};
/**
* Comparison between thinned and non-thinned effects.
* Demonstrates the 10% gradient fade at line ends.
* Comparison between different thinning effects.
* Demonstrates the gradient fade at line ends.
*/
export const ThinnedComparison: Story = {
render: () => (
<div className="space-y-8 w-96">
<div>
<h3 className="text-sm font-medium mb-2">With Thinned Ends (Default)</h3>
<Separator thinned />
<h3 className="text-sm font-medium mb-2">Full Thinned (Default)</h3>
<Separator thinned="thinned" />
</div>
<div>
<h3 className="text-sm font-medium mb-2">Without Thinned Ends</h3>
<Separator thinned={false} />
<h3 className="text-sm font-medium mb-2">Thinned Start Only</h3>
<Separator thinned="thinned-start" />
</div>
<div>
<h3 className="text-sm font-medium mb-2">Thinned End Only</h3>
<Separator thinned="thinned-end" />
</div>
<div>
<h3 className="text-sm font-medium mb-2">No Fading (None)</h3>
<Separator thinned="none" />
</div>
</div>
),
@@ -5,6 +5,11 @@ import { VStack } from '../VStack/VStack';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Options for thinning the separator line ends with a gradient fade.
*/
export type SeparatorThinnedType = 'thinned' | 'thinned-end' | 'thinned-start' | 'none';
/**
* Props for the Separator component.
*/
@@ -15,10 +20,32 @@ export interface SeparatorProps extends ClassNameProps, TestIdProps {
color?: 'black' | 'outline' | 'outline-light';
/** Orientation of the separator */
direction?: 'horizontal' | 'vertical';
/** Whether line ends should be thinned with gradient fade */
thinned?: boolean;
/**
* How line ends should be thinned with gradient fade.
* - 'thinned': Both ends fade out (default)
* - 'thinned-start': Only the start of the line fades out
* - 'thinned-end': Only the end of the line fades out
* - 'none': No fading, sharp edges
*/
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)]',
},
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)]',
},
};
/**
* A separator component for dividing content sections.
*
@@ -26,33 +53,18 @@ export interface SeparatorProps extends ClassNameProps, TestIdProps {
* - Single or double line variants inspired by newspaper layout traditions
* - Multiple color options (black, outline, outline-light) with theme support
* - Horizontal and vertical orientations
* - Optional thinned gradient fade effect at line ends (10% fade length)
* - 2px stroke width following print design standards
* - Optional thinned gradient fade effect at line ends ('thinned', 'thinned-start', 'thinned-end', 'none')
* - 1px stroke width for subtle division
* - Semantic HTML with proper ARIA attributes for accessibility
*
* The component uses CSS mask-image for the thinned effect and CSS custom properties
* for theme-aware colors. Double separators have 4px spacing between lines.
*
* @example
* ```tsx
* // Basic horizontal separator
* <Separator />
*
* // Double-line separator with black color
* <Separator type="double" color="black" />
*
* // Vertical separator without thinned ends
* <Separator direction="vertical" thinned={false} />
*
* // Light separator for subtle content division
* <Separator color="outline-light" />
* ```
*/
export const Separator = memo(function Separator({
type = 'single',
color,
direction = 'horizontal',
thinned = true,
thinned = 'thinned',
className,
'data-testid': testId,
}: SeparatorProps) {
@@ -63,8 +75,8 @@ export const Separator = memo(function Separator({
// Color mapping to CSS custom properties
const colorClasses = {
black: 'border-foreground',
outline: 'border-border',
'outline-light': 'border-muted',
outline: 'border-[var(--rb-outline)]',
'outline-light': 'border-[var(--rb-outline)]/40',
};
// Base line styles
@@ -72,18 +84,12 @@ export const Separator = memo(function Separator({
'border-0',
'border-solid',
colorClasses[finalColor],
// 2px stroke as specified
direction === 'horizontal' ? 'border-t-2' : 'border-l-2',
// 1px stroke for subtler appearance
direction === 'horizontal' ? 'border-t' : 'border-l',
];
// Thinned effect using CSS mask
const thinnedClasses = thinned
? [
direction === 'horizontal'
? '[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]'
: '[mask-image:linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]',
]
: [];
const thinnedClasses = thinned !== 'none' ? [THINNED_TYPE_CLASSES[direction][thinned]] : [];
// Size classes - fill container by default
const sizeClasses =
@@ -17,12 +17,16 @@ describe('components/Switch', () => {
const checkbox = screen.getByRole('switch');
await user.click(checkbox);
expect(handleChange).toHaveBeenCalledWith(true);
});
it('respects isSelected prop', () => {
render(<Switch isSelected onChange={() => {}}>Switch</Switch>);
render(
<Switch isSelected onChange={() => {}}>
Switch
</Switch>
);
const checkbox = screen.getByRole('switch');
expect(checkbox).toBeChecked();
});
@@ -30,11 +34,15 @@ describe('components/Switch', () => {
it('respects isDisabled prop', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<Switch isDisabled onChange={handleChange}>Switch</Switch>);
render(
<Switch isDisabled onChange={handleChange}>
Switch
</Switch>
);
const checkbox = screen.getByRole('switch');
expect(checkbox).toBeDisabled();
await user.click(checkbox);
expect(handleChange).not.toHaveBeenCalled();
});
+33 -25
View File
@@ -60,7 +60,7 @@ export function Switch(props: SwitchProps): React.JSX.Element {
const { inputProps } = useSwitch(props, state, ref);
const { isFocusVisible, focusProps } = useFocusRing();
const getVariantClasses = (): string => {
const getVariantStyles = (): React.CSSProperties => {
const isNegative = color.endsWith('-negative');
const baseColor = color.replace('-negative', '');
@@ -76,31 +76,22 @@ export function Switch(props: SwitchProps): React.JSX.Element {
const backgroundColor = isNegative ? colorValues.text : colorValues.base;
if (!state.isSelected) {
switch (variant) {
case 'solid':
return 'bg-[var(--rb-muted-base)]';
case 'outline':
return 'bg-transparent border-[var(--rb-muted-base)] border-2';
case 'ghost':
return 'bg-transparent border-transparent';
default:
return 'bg-[var(--rb-muted-base)]';
}
return {};
}
switch (variant) {
case 'solid':
return `bg-[${backgroundColor}]`;
return { backgroundColor };
case 'outline':
return `bg-transparent border-[${backgroundColor}] border-2`;
return { borderColor: backgroundColor };
case 'ghost':
return `bg-[${backgroundColor}]/20 border-transparent`;
return { backgroundColor: `${backgroundColor}33`, borderColor: 'transparent' }; // 33 is 20% opacity in hex
default:
return `bg-[${backgroundColor}]`;
return { backgroundColor };
}
};
const getThumbClasses = (): string => {
const getThumbStyles = (): React.CSSProperties => {
const isNegative = color.endsWith('-negative');
const baseColor = color.replace('-negative', '');
@@ -112,20 +103,20 @@ export function Switch(props: SwitchProps): React.JSX.Element {
neutral: { text: 'var(--rb-text)' },
};
const thumbColor = isNegative ? 'var(--rb-background)' : colors[baseColor].text;
const thumbColor = isNegative ? 'var(--rb-base)' : colors[baseColor].text;
if (!state.isSelected) {
return 'bg-[var(--rb-text)]';
return {};
}
switch (variant) {
case 'solid':
return `bg-[${thumbColor}]`;
return { backgroundColor: thumbColor };
case 'outline':
case 'ghost':
return `bg-[${isNegative ? 'var(--rb-text)' : 'var(--rb-' + baseColor + '-base)'}]`;
return { backgroundColor: isNegative ? 'var(--rb-text)' : `var(--rb-${baseColor}-base)` };
default:
return `bg-[${thumbColor}]`;
return { backgroundColor: thumbColor };
}
};
@@ -144,22 +135,39 @@ export function Switch(props: SwitchProps): React.JSX.Element {
aria-hidden="true"
className={cn(
'relative w-11 h-6 transition-all duration-200 ease-in-out rounded-full outline-none flex items-center',
getVariantClasses(),
!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',
isFocusVisible && 'ring-2 ring-offset-2 ring-[var(--rb-ring)]',
props.isDisabled && 'grayscale-[0.5]'
)}
style={getVariantStyles()}
>
<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',
getThumbClasses()
state.isSelected
? variant === 'outline'
? 'translate-x-4.5'
: 'translate-x-5'
: 'translate-x-0'
)}
style={getThumbStyles()}
/>
</div>
{children && (
<Text bold={state.isSelected} color={state.isSelected ? 'default' : 'muted'} size="s" type="label">
<Text
bold={state.isSelected}
color={state.isSelected ? 'default' : 'muted'}
size="s"
type="label"
>
{children}
</Text>
)}
Loaded 100 of 482 files, more files were not shown because too many files have changed in this diff. Show more