Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]anddeytenit a20e34a37c fix (ui): update PageHead test to match current component behavior
Agent-Logs-Url: https://github.com/deytenit/ermnvldmr.com/sessions/6131fb85-0d48-4782-8495-a5c48f64a016

Co-authored-by: deytenit <60275123+deytenit@users.noreply.github.com>
2026-05-04 15:05:03 +00:00
deytenit 208f6406e6 fix: Fix PR issues 2026-05-04 17:55:51 +03:00
deytenit 82236be7d6 feat: Add wanted page 2026-05-04 15:03:19 +03:00
deytenit 7ac7c1b405 feat: Add BentoGrid component 2026-05-04 14:42:54 +03:00
deytenit 77fa576ac6 feat: Add Accordion component 2026-05-04 13:52:57 +03:00
dependabot[bot] 07b16d404c build(deps): bump picomatch from 2.3.1 to 2.3.2 in the npm_and_yarn group across 0 directory (#14)
Updates `picomatch` from 2.3.1 to 2.3.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/micromatch/picomatch/releases">picomatch's
releases</a>.</em></p>
<blockquote>
<h2>2.3.2</h2>
<p>This is a security release fixing several security relevant
issues.</p>
<h2>What's Changed</h2>
<ul>
<li>fix: exception when glob pattern contains constructor by <a
href="https://github.com/Jason3S"><code>@​Jason3S</code></a> in <a
href="https://redirect.github.com/micromatch/picomatch/pull/144">micromatch/picomatch#144</a></li>
<li>Fix for <a
href="https://github.com/micromatch/picomatch/security/advisories/GHSA-c2c7-rcm5-vvqj">CVE-2026-33671</a></li>
<li>Fix for <a
href="https://github.com/micromatch/picomatch/security/advisories/GHSA-3v7f-55p6-f55p">CVE-2026-33672</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2">https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md">picomatch's
changelog</a>.</em></p>
<blockquote>
<h1>Release history</h1>
<p><strong>All notable changes to this project will be documented in
this file.</strong></p>
<p>The format is based on <a
href="http://keepachangelog.com/en/1.0.0/">Keep a Changelog</a>
and this project adheres to <a
href="http://semver.org/spec/v2.0.0.html">Semantic Versioning</a>.</p>
<!-- raw HTML omitted -->
<ul>
<li>Changelogs are for humans, not machines.</li>
<li>There should be an entry for every single version.</li>
<li>The same types of changes should be grouped.</li>
<li>Versions and sections should be linkable.</li>
<li>The latest version comes first.</li>
<li>The release date of each versions is displayed.</li>
<li>Mention whether you follow Semantic Versioning.</li>
</ul>
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<p>Changelog entries are classified using the following labels <em>(from
<a href="http://keepachangelog.com/">keep-a-changelog</a></em>):</p>
<ul>
<li><code>Added</code> for new features.</li>
<li><code>Changed</code> for changes in existing functionality.</li>
<li><code>Deprecated</code> for soon-to-be removed features.</li>
<li><code>Removed</code> for now removed features.</li>
<li><code>Fixed</code> for any bug fixes.</li>
<li><code>Security</code> in case of vulnerabilities.</li>
</ul>
<!-- raw HTML omitted -->
<h2>4.0.0 (2024-02-07)</h2>
<h3>Fixes</h3>
<ul>
<li>Fix bad text values in parse <a
href="https://redirect.github.com/micromatch/picomatch/issues/126">#126</a>,
thanks to <a
href="https://github.com/connor4312"><code>@​connor4312</code></a></li>
</ul>
<h3>Changed</h3>
<ul>
<li>Remove process global to work outside of node <a
href="https://redirect.github.com/micromatch/picomatch/issues/129">#129</a>,
thanks to <a
href="https://github.com/styfle"><code>@​styfle</code></a></li>
<li>Add sideEffects to package.json <a
href="https://redirect.github.com/micromatch/picomatch/issues/128">#128</a>,
thanks to <a
href="https://github.com/frandiox"><code>@​frandiox</code></a></li>
<li>Removed <code>os</code>, make compatible browser environment. See <a
href="https://redirect.github.com/micromatch/picomatch/issues/124">#124</a>,
thanks to <a
href="https://github.com/gwsbhqt"><code>@​gwsbhqt</code></a></li>
</ul>
<h2>3.0.1</h2>
<h3>Fixes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/micromatch/picomatch/commit/81cba8d4b767cab3cb29d26eb4f691eed75b73b2"><code>81cba8d</code></a>
Publish 2.3.2</li>
<li><a
href="https://github.com/micromatch/picomatch/commit/fc1f6b69006e9435caf8fb40d8aff378bc0b7bce"><code>fc1f6b6</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/micromatch/picomatch/commit/eec17aee5428a7249e9ca5adbb8a0d28fa29619b"><code>eec17ae</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/micromatch/picomatch/commit/78f8ca4362d9e66cadea97b93e292f10096452ed"><code>78f8ca4</code></a>
Merge pull request <a
href="https://redirect.github.com/micromatch/picomatch/issues/156">#156</a>
from micromatch/backport-144</li>
<li><a
href="https://github.com/micromatch/picomatch/commit/3f4f10eaa65bf3a52e8f2999674cd27e11fa3c9b"><code>3f4f10e</code></a>
Merge pull request <a
href="https://redirect.github.com/micromatch/picomatch/issues/144">#144</a>
from Jason3S/jdent-object-properties</li>
<li>See full diff in <a
href="https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

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

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 00:59:13 +03:00
dependabot[bot] a103313480 build(deps-dev): bump vite from 7.3.1 to 7.3.2 in the npm_and_yarn group across 1 directory (#12)
Bumps the npm_and_yarn group with 1 update in the / directory:
[vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).

Updates `vite` from 7.3.1 to 7.3.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v7.3.2</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v7.3.1...v7.3.2">7.3.2</a>
(2026-04-06)<!-- raw HTML omitted --></h2>
<h3>Bug Fixes</h3>
<ul>
<li>avoid path traversal with optimize deps sourcemap handler (<a
href="https://redirect.github.com/vitejs/vite/issues/22161">#22161</a>)
(<a
href="https://github.com/vitejs/vite/commit/09d8c903bde12fee2710314d3b42bc789c686df7">09d8c90</a>)</li>
<li>backport <a
href="https://redirect.github.com/vitejs/vite/issues/22159">#22159</a>,
apply server.fs check to env transport (<a
href="https://redirect.github.com/vitejs/vite/issues/22162">#22162</a>)
(<a
href="https://github.com/vitejs/vite/commit/19db0f29c3a3ac4e64cc95c270716c77fd223ad1">19db0f2</a>)</li>
<li>check <code>server.fs</code> after stripping query as well (<a
href="https://redirect.github.com/vitejs/vite/issues/22160">#22160</a>)
(<a
href="https://github.com/vitejs/vite/commit/f8103cc946f137a54e395fe3f5d08e8209231ed6">f8103cc</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/cc383e07b66d4c5a9768fcb570e0af812cb8d999"><code>cc383e0</code></a>
release: v7.3.2</li>
<li><a
href="https://github.com/vitejs/vite/commit/09d8c903bde12fee2710314d3b42bc789c686df7"><code>09d8c90</code></a>
fix: avoid path traversal with optimize deps sourcemap handler (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22161">#22161</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/f8103cc946f137a54e395fe3f5d08e8209231ed6"><code>f8103cc</code></a>
fix: check <code>server.fs</code> after stripping query as well (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22160">#22160</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/19db0f29c3a3ac4e64cc95c270716c77fd223ad1"><code>19db0f2</code></a>
fix: backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22159">#22159</a>,
apply server.fs check to env transport (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22162">#22162</a>)</li>
<li>See full diff in <a
href="https://github.com/vitejs/vite/commits/v7.3.2/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

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

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 00:49:32 +03:00
deytenit 51a783f93a docs: Update docs for the correctness (#13) 2026-04-15 00:48:06 +03:00
deytenit 8dba55272c docs: update references to renamed commons repository (#11) 2026-04-06 22:18:55 +03:00
deytenit 6b0a893c86 Update package.json to remove broken postinstall (#10) 2026-03-20 01:41:14 +03:00
deytenit 575959ffa2 Add joplin web article (#9) 2026-03-20 01:32:26 +03:00
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
566 changed files with 15322 additions and 19757 deletions

No files matched your search

+5 -5
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 >}}
```
@@ -320,7 +320,7 @@ Use GitHub-flavored alerts for emphasis.
## 6. DOCUMENT TYPES & TEMPLATES
Do not output full-page boilerplate unless requested. Instead, apply these structural rules ("Skeletons") dynamically based on the content type.
This is also serves as the fixed categories which should populate every project's docs: Getting Started, Guides, Recipes, Concepts, Troubleshooting.
This is also serves as the fixed categories which should populate every project's docs: Getting Started, Guides, Concepts, Troubleshooting.
### 6.1 General Skeleton (All Docs)
@@ -349,7 +349,7 @@ This is also serves as the fixed categories which should populate every project'
3. **Procedure:** Strict numbered steps (or `{{% steps %}}`).
4. **Result Control:** How to verify success.
### 6.4 How-to / Recipe (Task-Oriented)
### 6.4 How-to / Guide (Task-Oriented)
- **Goal:** Solve a specific problem.
- **Structure:**
@@ -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,6 +63,7 @@ jobs:
path: |
packages/rsbuild-config/dist
packages/storybook-config/dist
packages/i18n/dist
retention-days: 1
lint-www:
@@ -60,6 +60,8 @@ jobs:
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build Storybook config
run: pnpm --filter @ermnvldmr/storybook-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Config Artifacts
uses: actions/upload-artifact@v4
with:
@@ -67,6 +69,7 @@ jobs:
path: |
packages/rsbuild-config/dist
packages/storybook-config/dist
packages/i18n/dist
retention-days: 1
lint-www:
+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
+25 -4
View File
@@ -33,7 +33,7 @@
│ ├── stl/ # Standard Type Library (utilities)
│ └── ui/ # Shared React component library
├── services/ # Independent web services
│ ├── www/ # Main website (Astro + React)
│ ├── www/ # Main website (Rsbuild + React)
│ └── docs/ # Documentation site (Hugo)
├── .github/workflows/ # CI/CD automation
├── pnpm-workspace.yaml # Workspace configuration
@@ -44,7 +44,7 @@
#### Configuration Packages (`packages/`)
- **eslint-config**: Shared ESLint rules for TypeScript, React, and Astro
- **eslint-config**: Shared ESLint rules for TypeScript and React
- **rsbuild-config**: Shared Rsbuild and Rspack configurations for library and service builds
- **storybook-config**: Shared Storybook setup and configurations
@@ -52,10 +52,12 @@
- **stl** (Standard Type Library): Common utilities, types, and helper functions
- **ui**: React component library with design system and Storybook stories
- **i18n**: Internationalization utilities
#### Service Packages (`services/`)
- **www**: Main website built with Astro.js, React, and Tailwind CSS
- **www**: Main website built with Rsbuild, React, and Tailwind CSS
- **static**: Static landing pages service built with Rsbuild and React
- **docs**: Documentation site built with Hugo and Hextra theme
### Key Directory Rules
@@ -140,7 +142,9 @@ All packages use the `@ermnvldmr/` namespace:
- `@ermnvldmr/storybook-config` - Shared Storybook configurations
- `@ermnvldmr/stl` - Standard Type Library
- `@ermnvldmr/ui` - Component library
- `@ermnvldmr/i18n` - Internationalization utilities
- `@ermnvldmr/www` - Main website service
- `@ermnvldmr/static` - Static landing pages service
- `@ermnvldmr/docs` - Documentation service
### TypeScript Guidelines
@@ -175,6 +179,15 @@ export function cn(...inputs: ClassValue[]): string {
}
```
### Internationalization (i18n) Guidelines
- **Package**: Use the `@ermnvldmr/i18n` package for all translations across services.
- **Keyset Pattern**: Create `.i18n.ts` files alongside components that require translation.
- **Russian Translation Style**: Embrace the Russian language fully. Prefer phonetic transliteration/transcription for global brand names and descriptive terms over keeping them in English.
- **Brands**: 'GitHub' → 'ГитХаб', 'Instagram' → 'Инстаграм', 'Telegram' → 'Телеграм'.
- **Descriptive**: 'Email' → 'Электропочта'.
- **Source of Truth**: The English locale (`en`) serves as the primary reference for keys and structure.
### Component Development Patterns
#### UI Package Components
@@ -306,7 +319,7 @@ Each configuration package provides standardized setups:
#### ESLint Configuration
```javascript
// Provides: base, react, astro configurations
// Provides: base, react configurations
import { baseConfig } from '@ermnvldmr/eslint-config/dev';
```
@@ -421,3 +434,11 @@ describe('components/paper/Button/Button', () => {
---
_This guide serves as the architectural foundation for the monorepo. Focus on these stable patterns and structures rather than specific implementation details that may change over time._
## 🤖 AI Agent Best Practices
The following are explicit best practices and preferences intended for AI tools and agents operating within this repository:
- **Build System Preference**: Rsbuild and Rspack are the standard and strongly preferred build tools for all projects in this monorepo due to their Rust-based architecture and performance. Do not introduce Vite, Astro, or Webpack unless strictly necessary.
- **Framework Preference**: The monorepo has migrated away from Astro in favor of React + Rsbuild (with MDX support).
- **Content Sites**: Rspress is the preferred infrastructure for any new content-heavy or documentation sites.
+3 -1
View File
@@ -23,13 +23,15 @@ Composite (see [Copyright & License](#copyright--license))
#### Services
- **[www](./services/www)**: Main website, built with Astro and React.
- **[www](./services/www)**: Main website, built with Rsbuild, React, and MDX.
- **[static](./services/static)**: Static landing pages, built with Rsbuild and React.
- **[docs](./services/docs)**: Documentation site, built with Hugo and the Hextra theme.
#### Packages
- **[@ermnvldmr/ui](./packages/ui)**: Shared React component library and design system.
- **[@ermnvldmr/stl](./packages/stl)**: Standard Template Library – common utilities and reusable logic.
- **[@ermnvldmr/i18n](./packages/i18n)**: Internationalization utilities.
- **[@ermnvldmr/eslint-config](./packages/eslint-config)**: Shared linting configurations.
- **[@ermnvldmr/rsbuild-config](./packages/rsbuild-config)**: Shared build configurations (Rsbuild & Rspack).
- **[@ermnvldmr/storybook-config](./packages/storybook-config)**: Shared Storybook configurations.
+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
-11
View File
@@ -1,6 +1,5 @@
// @ts-check
import { baseConfig } from '@ermnvldmr/eslint-config/dev';
import eslintPluginAstro from 'eslint-plugin-astro';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
@@ -22,20 +21,10 @@ export default [
'**/coverage/**',
'**/storybook-static/**',
'**/.next/**',
'**/.astro/**',
'**/*.d.ts',
],
},
// Astro
...eslintPluginAstro.configs.recommended,
{
files: ['services/www/**/*.astro', 'services/static/**/*.astro'],
rules: {
// Astro specific rules can go here
},
},
// Storybook - recommended configuration for flat config
...storybook.configs['flat/recommended'],
+2 -1
View File
@@ -43,7 +43,8 @@
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test",
"test:coverage": "pnpm -r test:coverage",
"clean": "pnpm -r clean && rm -rf node_modules"
"clean": "pnpm -r clean && rm -rf node_modules",
"build:deps": "pnpm -r --filter='./packages/*-config' build && pnpm --filter='./packages/i18n' build"
},
"devDependencies": {
"globals": "^16.0.0",
-1
View File
@@ -13,7 +13,6 @@
"dependencies": {
"@eslint/js": "^9.39.2",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-astro": "^1.3.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsdoc": "^61.5.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
@@ -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.
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@ermnvldmr/i18n",
"version": "1.0.0",
"description": "Internationalization utilities for ermnvldmr projects",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./src/index.ts"
},
"./dev": {
"import": "./dist/dev.js",
"types": "./dist/dev.d.ts",
"default": "./src/dev.ts"
},
"./testing": {
"import": "./dist/testing.js",
"types": "./dist/testing.d.ts",
"default": "./src/testing.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "rsbuild build",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src --max-warnings 0",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist coverage"
},
"peerDependencies": {
"react": "^19.1.0"
},
"devDependencies": {
"@ermnvldmr/eslint-config": "workspace:*",
"@ermnvldmr/rsbuild-config": "workspace:*",
"@rsbuild/core": "^1.2.11",
"@types/react": "^19.1.2",
"@vitest/coverage-v8": "^4.0.18",
"react": "^19.1.0",
"typescript": "^5.8.3",
"vitest": "^4.0.18"
}
}
+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';
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { createKeyset } from './keyset.js';
const { t, tRaw } = createKeyset({
greeting: { en: 'Hello', ru: 'Привет' },
'count {value}': { en: 'count {value}', ru: 'счёт {value}' },
items: {
en: { none: 'no items', one: 'one item', some: '{count} items', many: '{count} items' },
ru: {
none: 'нет элементов',
one: 'один элемент',
some: '{count} элемента',
many: '{count} элементов',
},
},
});
describe('t', () => {
it('returns plain translation', () => expect(t('greeting')).toBe('Hello'));
it('interpolates {value}', () => expect(t('count {value}', { value: '42' })).toBe('count 42'));
it('pluralizes none', () => expect(t('items', { count: 0 })).toBe('no items'));
it('pluralizes one', () => expect(t('items', { count: 1 })).toBe('one item'));
it('pluralizes many with interpolation', () => expect(t('items', { count: 5 })).toBe('5 items'));
});
describe('tRaw', () => {
it('returns string node for plain key', () => expect(tRaw('greeting')).toBe('Hello'));
it('interpolates ReactNode', () => {
const node = tRaw('count {value}', { value: React.createElement('strong', null, '42') });
expect(Array.isArray(node)).toBe(true);
});
});
+138
View File
@@ -0,0 +1,138 @@
import { LOCALE } from './locale.js';
import { pluralize, type PluralForms } from './pluralize.js';
import type { ReactNode } from 'react';
/** A translation entry with a plain string per locale. */
interface PlainTranslation {
en: string;
ru: string;
}
/** A translation entry with plural forms per locale. */
interface PluralTranslation {
en: PluralForms;
ru: PluralForms;
}
/** Union of all supported translation shapes. */
type Translation = PlainTranslation | PluralTranslation;
/** A map of translation keys to their translation entries. */
type Keyset = Record<string, Translation>;
/**
* Returns `true` when the translation entry uses plural forms.
*
* @param t - The translation entry to inspect.
* @returns Type predicate narrowing `t` to {@link PluralTranslation}.
* @example
* isPluralTranslation({ en: 'hello', ru: 'привет' }) // → false
*/
function isPluralTranslation(t: Translation): t is PluralTranslation {
return typeof t.en === 'object';
}
/**
* Replaces `{key}` placeholders in `template` with values from `params`.
*
* @param template - String containing `{key}` placeholders.
* @param params - Map of placeholder names to string/number values.
* @returns The interpolated string.
* @example
* interpolate('Hello {name}!', { name: 'World' }) // → 'Hello World!'
*/
function interpolate(template: string, params: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) =>
key in params ? String(params[key]) : `{${key}}`
);
}
/**
* Splits `template` on `{key}` placeholders and substitutes ReactNode values.
*
* @param template - String containing `{key}` placeholders.
* @param params - Map of placeholder names to ReactNode values.
* @returns A single string when all parts are strings, otherwise a ReactNode array.
* @example
* interpolateRaw('Click {link}', { link: <a href="/">here</a> })
*/
function interpolateRaw(template: string, params: Record<string, ReactNode>): ReactNode {
const parts = template.split(/(\{[^}]+\})/g);
const result = parts.map((part) => {
const match = /^\{(\w+)\}$/.exec(part);
if (match) {
const key = match[1];
return key in params ? params[key] : part;
}
return part;
});
if (result.every((p): p is string => typeof p === 'string')) {
return result.join('');
}
return result;
}
/**
* Creates a typed `t` and `tRaw` pair bound to `keyset`.
*
* @param keyset - An object mapping translation keys to their locale entries.
* @returns `{ t, tRaw }` translation helpers.
* @example
* const { t } = createKeyset({ greeting: { en: 'Hello', ru: 'Привет' } });
* t('greeting') // → 'Hello' (in EN build)
*/
export function createKeyset<const K extends Keyset>(keyset: K) {
/** String union of all keys defined in `keyset`. */
type Keys = keyof K & string;
/**
* Returns the translated string for `key`, with optional interpolation and pluralisation.
*
* @param key - A key defined in the keyset.
* @param params - Optional interpolation/pluralisation params.
* @returns The translated string for the current build locale.
* @example
* t('greeting') // → 'Hello'
* t('items {count}', { count: 3 }) // → '3 items'
*/
function t(key: Keys, params?: Record<string, string | number>): string {
const entry = keyset[key];
if (isPluralTranslation(entry)) {
const forms = entry[LOCALE];
const count = typeof params?.count === 'number' ? params.count : 0;
const template = pluralize(count, forms);
return params ? interpolate(template, params) : template;
}
const template = entry[LOCALE];
return params ? interpolate(template, params) : template;
}
/**
* Returns the translated ReactNode for `key`, allowing ReactNode interpolation values.
*
* @param key - A key defined in the keyset.
* @param params - Optional interpolation params; values may be any ReactNode.
* @returns The translated string or ReactNode array for the current build locale.
* @example
* tRaw('click {link}', { link: <a href="/">here</a> })
*/
function tRaw(key: Keys, params?: Record<string, ReactNode>): ReactNode {
const entry = keyset[key];
if (isPluralTranslation(entry)) {
const forms = entry[LOCALE];
const countParam = params?.count;
const count = typeof countParam === 'number' ? countParam : 0;
const template = pluralize(count, forms);
return params ? interpolateRaw(template, params) : template;
}
const template = entry[LOCALE];
return params ? interpolateRaw(template, params) : template;
}
return { t, tRaw };
}
+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();
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { localePath, localeHref } from './path.js';
describe('localePath', () => {
it('prepends LOCALE to absolute path', () =>
expect(localePath('/articles')).toBe('/en/articles'));
it('handles root path', () => expect(localePath('/')).toBe('/en/'));
it('handles nested path', () =>
expect(localePath('/articles/2026/test')).toBe('/en/articles/2026/test'));
});
describe('localeHref', () => {
it('replaces locale prefix in URL', () =>
expect(localeHref('/en/articles', 'ru')).toBe('/ru/articles'));
it('replaces locale at root', () => expect(localeHref('/en/', 'ru')).toBe('/ru/'));
it('falls back to path if no prefix found', () =>
expect(localeHref('/articles', 'ru')).toBe('/ru/articles'));
});
+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"]
}
+2
View File
@@ -12,8 +12,10 @@
"dependencies": {
"@rsbuild/plugin-react": "^1.1.1",
"@rsbuild/plugin-type-check": "^1.1.1",
"critters": "^0.0.25",
"fast-glob": "^3.3.3",
"jsdom": "^28.0.0",
"react": "^19.1.0",
"rsbuild-plugin-dts": "^0.19.5"
},
"devDependencies": {
@@ -11,21 +11,21 @@ export function discoverEntries(root: string, pattern: string): Record<string, s
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;
}
@@ -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,8 +21,8 @@ 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',
@@ -33,30 +36,10 @@ export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
meta: {
description: 'Thoughts and creations of mine',
},
tags: [
{
tag: 'link',
attrs: {
rel: 'preload',
as: 'font',
type: 'font/woff2',
href: '/static/font/lato-var.woff2',
crossorigin: true,
},
},
{
tag: 'style',
children: `
:root { background-color: #f6f6f6; color: #262626; }
.dark { background-color: #262626; color: #f6f6f6; }
body { margin: 0; transition: background-color 0.2s ease; }
`,
head: true,
},
],
},
...config,
});
};
return defineConfig(mergeConfig(defaultConfig, config));
}
/**
@@ -74,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: {
@@ -86,6 +69,7 @@ export function definePackageConfig(config: RsbuildConfig = {}): RsbuildConfig {
tools: {
htmlPlugin: false,
},
...config,
});
};
return defineConfig(mergeConfig(defaultConfig, config));
}
+172 -4
View File
@@ -1,16 +1,184 @@
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import React from 'react';
import type { RsbuildPlugin } from '@rsbuild/core';
/**
* A minimal Rsbuild plugin for build-time Static Site Generation.
* Placeholder for future extension.
* Rsbuild plugin for build-time Static Site Generation.
*
* After the web build completes:
* 1. Builds each entry as a Node.js CJS bundle (dist-ssr/)
* 2. Renders each entry with react-dom/server renderToString
* 3. Injects the rendered HTML into the web build's HTML files
* 4. Runs critters to inline critical CSS and make the main stylesheet async
* 5. Cleans up dist-ssr/
*/
export function ssgPlugin(): RsbuildPlugin {
return {
name: 'ermnvldmr:ssg-plugin',
setup(api) {
api.onAfterBuild(async ({ stats }) => {
if (!stats) return;
console.log('[ssg-plugin] Build finished.');
if (!stats) {
console.warn('[ssg-plugin] No stats available, skipping SSG.');
return;
}
const config = api.getRsbuildConfig();
const rawEntries = config.source?.entry;
if (!rawEntries || typeof rawEntries !== 'object' || Array.isArray(rawEntries)) {
console.warn('[ssg-plugin] source.entry is not an object map, skipping SSG.');
return;
}
// Normalise entries: value may be a string path or an EntryDescription object
const entries: Record<string, string> = {};
for (const [name, value] of Object.entries(rawEntries)) {
if (typeof value === 'string') {
entries[name] = value;
} else if (value && typeof value === 'object' && 'import' in value) {
const imp = (value as { import: string | string[] }).import;
entries[name] = Array.isArray(imp) ? imp[0] : imp;
}
}
const entryNames = Object.keys(entries);
if (entryNames.length === 0) {
console.warn('[ssg-plugin] No entries to process, skipping SSG.');
return;
}
// api.context.distPath is the absolute path to the web build output directory.
// Fallback: derive from rootPath + output.distPath.root if the property is absent.
const webDistPath: string =
(api.context as { distPath?: string }).distPath ??
path.join(
api.context.rootPath,
(config.output?.distPath as { root?: string } | undefined)?.root ?? 'dist'
);
const ssrDistPath = path.join(path.dirname(webDistPath), 'dist-ssr');
// ── Step 1: Build node-target CJS bundles ─────────────────────────────
console.log('[ssg-plugin] Building SSR bundles...');
const { createRsbuild } = await import('@rsbuild/core');
// Reuse web build plugins minus ssgPlugin (avoids recursion) and pluginTypeCheck
// (type-checking is not needed for the SSR build). This automatically includes
// pluginReact, pluginMdx, and any other service-level plugins.
const SSG_PLUGIN_NAME = 'ermnvldmr:ssg-plugin';
const TYPE_CHECK_PLUGIN_NAME = 'rsbuild:type-check';
const ssrPlugins = (config.plugins ?? []).filter((p) => {
if (!p || typeof p !== 'object') return true;
const name = (p as { name?: string }).name;
return name !== SSG_PLUGIN_NAME && name !== TYPE_CHECK_PLUGIN_NAME;
});
const ssrBuild = await createRsbuild({
rsbuildConfig: {
plugins: ssrPlugins,
source: { entry: entries },
tools: config.tools,
output: {
target: 'node',
distPath: { root: ssrDistPath, js: '.' },
filename: { js: '[name].cjs' },
// Externalize React so the SSR bundle shares the same React instance
// as react-dom/server, preventing "null dispatcher" hook errors.
externals: {
react: 'commonjs react',
'react-dom': 'commonjs react-dom',
'react/jsx-runtime': 'commonjs react/jsx-runtime',
'react/jsx-dev-runtime': 'commonjs react/jsx-dev-runtime',
},
},
},
});
await ssrBuild.build();
console.log('[ssg-plugin] SSR bundles ready.');
// ── Step 2: Render, inject, inline critical CSS ───────────────────────
const { renderToString } = await import('react-dom/server');
// createRequire from ESM context — needed to load CJS modules (critters, SSR bundles)
const _require = createRequire(import.meta.url);
const Critters = _require('critters') as new (opts?: {
path?: string;
publicPath?: string;
preload?: string;
inlineFonts?: boolean;
pruneSource?: boolean;
reduceInlineStyles?: boolean;
}) => { process(html: string): Promise<string> };
// critters `path` is the filesystem root that corresponds to publicPath.
// It resolves <link href="..."> to webDistPath. Use configured assetPrefix for locale builds.
const configuredAssetPrefix =
(config.output as { assetPrefix?: string } | undefined)?.assetPrefix ?? '/';
const critters = new Critters({
path: webDistPath,
publicPath: configuredAssetPrefix,
preload: 'swap', // <link rel="preload" as="style" onload="this.rel='stylesheet'">
inlineFonts: false, // fonts are handled via separate <link rel="preload"> tags
pruneSource: false, // keep full CSS file for post-hydration correctness
reduceInlineStyles: false, // preserve theme init styles (prevents .dark rule from being stripped)
});
for (const entryName of entryNames) {
const ssrBundlePath = path.join(ssrDistPath, `${entryName}.cjs`);
const webHtmlPath = path.join(webDistPath, `${entryName}.html`);
// Clear module cache so each entry loads fresh
const resolved = _require.resolve(ssrBundlePath);
delete _require.cache[resolved];
const mod = _require(ssrBundlePath) as { default?: React.ComponentType };
const Component = mod.default;
if (!Component) {
console.warn(`[ssg-plugin] Entry "${entryName}" has no default export — skipping.`);
continue;
}
// Render to HTML string
const renderedHtml = renderToString(
React.createElement(React.StrictMode, null, React.createElement(Component))
);
// Read web HTML, inject rendered content
const source = await fs.readFile(webHtmlPath, 'utf8');
const injected = source.replace(
/<div id="root"[^>]*>\s*<\/div>/,
`<div id="root">${renderedHtml}</div>`
);
if (injected === source) {
console.warn(
`[ssg-plugin] Could not find <div id="root"> in ${webHtmlPath} — injection skipped.`
);
}
// Run critters: inline critical CSS, make main stylesheet async
const processed = await critters.process(injected);
// Set <html lang> to the current build locale
const locale = process.env.LOCALE ?? 'en';
const localized = processed.replace(
/<html([^>]*)\slang="[^"]*"/,
`<html$1 lang="${locale}"`
);
await fs.writeFile(webHtmlPath, localized, 'utf8');
console.log(`[ssg-plugin] ✓ ${entryName}`);
}
// ── Step 3: Cleanup ───────────────────────────────────────────────────
await fs.rm(ssrDistPath, { recursive: true, force: true });
console.log('[ssg-plugin] SSG complete. dist-ssr cleaned up.');
});
},
};
+1
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": [
+18 -1
View File
@@ -1,5 +1,18 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { extendTailwindMerge } from 'tailwind-merge';
const RB_TYPES = ['display', 'headline', 'title', 'body', 'label'] as const;
const RB_SIZES = ['l', 'm', 's'] as const;
const rbFontSizeClasses = RB_TYPES.flatMap((type) => RB_SIZES.map((size) => `${type}-${size}`));
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': [{ 'text-rb': rbFontSizeClasses }],
},
},
});
/**
* Combines and merges CSS class names using clsx and tailwind-merge.
@@ -7,6 +20,10 @@ import { twMerge } from 'tailwind-merge';
* This utility function combines the power of clsx for conditional class handling
* with tailwind-merge for proper Tailwind CSS class deduplication and conflict resolution.
*
* Tailwind-merge is configured to recognise `text-rb-{type}-{size}` classes
* (e.g. `text-rb-body-m`) as font-size utilities so they do not conflict with
* text-color classes such as `text-[var(--rb-text)]`.
*
* @param inputs - Class values to combine (strings, objects, arrays, etc.)
* @returns Merged class string with conflicts resolved
*
+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', () => {
+16 -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,19 @@ export interface StorageManager<T> {
* const theme = createLocalStorage('ermnvldmr/ui/lib/theme', 'system');
* console.log(theme.get());
* ```
*
* @throws Error if window or localStorage is undefined.
*/
export function createLocalStorage<T>(key: string, defaultValue: T): StorageManager<T> {
if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
throw new Error(
`[createLocalStorage] Attempted to access storage for key "${key}" outside of a browser environment. ` +
'This utility is strictly for client-side use.'
);
return {
key,
get: () => defaultValue,
set: () => {
/* no-op in SSR/SSG context */
},
remove: () => {
/* no-op in SSR/SSG context */
},
};
}
const manager: StorageManager<T> = {
@@ -51,7 +56,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;
}
},
+1 -1
View File
@@ -30,6 +30,6 @@
"babel-plugin-istanbul": "^7.0.1",
"storybook-react-rsbuild": "^1.0.3",
"typescript": "^5.8.3",
"vite": "^7.2.2"
"vite": "^8.0.5"
}
}
+47
View File
@@ -1 +1,48 @@
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",
@@ -0,0 +1,112 @@
import React from 'react';
import { Accordion } from './Accordion';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Accordion> = {
title: 'Components/Accordion',
component: Accordion,
tags: ['autodocs'],
argTypes: {
type: {
control: 'select',
options: ['single', 'multiple'],
},
collapsible: {
control: 'boolean',
},
},
};
export default meta;
type Story = StoryObj<typeof Accordion>;
export const Default: Story = {
args: {
type: 'single',
collapsible: true,
},
render: (args) => (
<Accordion {...args}>
<Accordion.Item value="item-1">
<Accordion.Trigger level={2}>Is it accessible?</Accordion.Trigger>
<Accordion.Content>
Yes. It adheres to the WAI-ARIA design pattern.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger level={2}>Is it styled?</Accordion.Trigger>
<Accordion.Content>
Yes. It comes with default styles that matches the design system.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-3">
<Accordion.Trigger level={2}>Is it animated?</Accordion.Trigger>
<Accordion.Content>
Yes. It uses CSS Grid transitions for smooth height animation.
</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
export const Multiple: Story = {
args: {
type: 'multiple',
},
render: (args) => (
<Accordion {...args}>
<Accordion.Item value="item-1">
<Accordion.Trigger level={3}>Section One</Accordion.Trigger>
<Accordion.Content>
This section can be open alongside others.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger level={3}>Section Two</Accordion.Trigger>
<Accordion.Content>
You can open as many sections as you want in multiple mode.
</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
export const DifferentHeaderLevels: Story = {
render: () => (
<Accordion type="single">
<Accordion.Item value="h1">
<Accordion.Trigger level={1}>Header Level 1</Accordion.Trigger>
<Accordion.Content>Content for H1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="h2">
<Accordion.Trigger level={2}>Header Level 2</Accordion.Trigger>
<Accordion.Content>Content for H2</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="h3">
<Accordion.Trigger level={3}>Header Level 3</Accordion.Trigger>
<Accordion.Content>Content for H3</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="h4">
<Accordion.Trigger level={4}>Header Level 4</Accordion.Trigger>
<Accordion.Content>Content for H4</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
export const Disabled: Story = {
render: () => (
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger level={3}>Enabled Item</Accordion.Trigger>
<Accordion.Content>This one works fine.</Accordion.Content>
</Accordion.Item>
<Accordion.Item disabled value="item-2">
<Accordion.Trigger level={3}>Disabled Item</Accordion.Trigger>
<Accordion.Content>You should not see this.</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
@@ -0,0 +1,221 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { vi } from 'vitest';
import { Accordion } from './Accordion';
describe('Accordion', () => {
it('renders correctly with all sub-components', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger level={2}>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
expect(screen.getByText('Trigger 1')).toBeInTheDocument();
expect(screen.getByText('Content 1')).toBeInTheDocument();
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
});
it('toggles content on click in single mode', async () => {
const user = userEvent.setup();
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const trigger1 = screen.getByText('Trigger 1');
const trigger2 = screen.getByText('Trigger 2');
await user.click(trigger1);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'false');
await user.click(trigger2);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('allows multiple items to be open in multiple mode', async () => {
const user = userEvent.setup();
render(
<Accordion type="multiple">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
await user.click(screen.getByText('Trigger 1'));
await user.click(screen.getByText('Trigger 2'));
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('respects the collapsible prop in single mode', async () => {
const user = userEvent.setup();
render(
<Accordion collapsible={false} type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const trigger = screen.getByText('Trigger 1');
// Open it
await user.click(trigger);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
// Try to close it (should stay open because collapsible={false})
await user.click(trigger);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
});
it('does not toggle when disabled', async () => {
const user = userEvent.setup();
render(
<Accordion type="single">
<Accordion.Item disabled value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const trigger = screen.getByText('Trigger 1');
await user.click(trigger);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button')).toBeDisabled();
});
it('works as a controlled component', async () => {
const user = userEvent.setup();
const TestComponent = () => {
const [value, setValue] = React.useState<string[]>(['item-1']);
return (
<Accordion type="single" value={value} onValueChange={setValue}>
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
};
render(<TestComponent />);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
await user.click(screen.getByText('Trigger 2'));
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('works as an uncontrolled component with defaultValue', () => {
render(
<Accordion defaultValue={['item-2']} type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('renders with valid HTML structure (heading containing button)', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger level={3}>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const heading = screen.getByRole('heading', { level: 3 });
const button = screen.getByRole('button');
expect(heading).toContainElement(button);
});
it('applies accessibility attributes to content when closed', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const content = screen.getByText('Content 1').closest('[role="region"]');
expect(content).toHaveAttribute('aria-hidden', 'true');
expect(content).toHaveAttribute('inert');
});
it('removes accessibility hiding attributes when open', async () => {
const user = userEvent.setup();
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
await user.click(screen.getByText('Trigger 1'));
const content = screen.getByText('Content 1').closest('[role="region"]');
expect(content).toHaveAttribute('aria-hidden', 'false');
expect(content).not.toHaveAttribute('inert');
});
it('throws error when sub-components are used outside of Accordion', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<Accordion.Trigger>Trigger</Accordion.Trigger>)).toThrow(
'Accordion sub-components must be used within an Accordion component.'
);
expect(() => render(<Accordion.Item value="1">Item</Accordion.Item>)).toThrow(
'Accordion sub-components must be used within an Accordion component.'
);
expect(() => render(<Accordion.Content>Content</Accordion.Content>)).toThrow(
'AccordionItem sub-components must be used within an AccordionItem component.'
);
consoleSpy.mockRestore();
});
});
@@ -0,0 +1,95 @@
import { cn } from '@ermnvldmr/stl';
import React, { useState, useCallback, useMemo } from 'react';
import { AccordionContent } from './AccordionContent/AccordionContent';
import { AccordionItem } from './AccordionItem/AccordionItem';
import { AccordionTrigger } from './AccordionTrigger/AccordionTrigger';
import { AccordionContext } from './contexts/AccordionContext/AccordionContext';
import type { AccordionProps } from './types';
/**
* An Accordion component that allows users to reveal and hide content sections.
*
* Built with a compound component pattern for maximum flexibility.
* @example Basic usage:
*/
const AccordionRoot = ({
type = 'single',
value: controlledValue,
defaultValue = [],
onValueChange,
collapsible = true,
children,
className,
'data-testid': testId,
}: AccordionProps) => {
const [internalValue, setInternalValue] = useState<string[]>(defaultValue);
const isControlled = controlledValue !== undefined;
const currentValues = isControlled ? controlledValue : internalValue;
const handleValueChange = useCallback(
(itemValue: string) => {
let nextValues: string[];
if (type === 'single') {
const isCurrentOpen = currentValues.includes(itemValue);
if (isCurrentOpen && collapsible) {
nextValues = [];
} else if (isCurrentOpen && !collapsible) {
nextValues = [itemValue];
} else {
nextValues = [itemValue];
}
} else {
nextValues = currentValues.includes(itemValue)
? currentValues.filter((v) => v !== itemValue)
: [...currentValues, itemValue];
}
if (!isControlled) {
setInternalValue(nextValues);
}
onValueChange?.(nextValues);
},
[type, currentValues, collapsible, isControlled, onValueChange]
);
const contextValue = useMemo(
() => ({
type,
value: currentValues,
onValueChange: handleValueChange,
collapsible,
}),
[type, currentValues, handleValueChange, collapsible]
);
return (
<AccordionContext.Provider value={contextValue}>
<div className={cn('w-full', className)} data-testid={testId}>
{children}
</div>
</AccordionContext.Provider>
);
};
/**
* A collapsible accordion component system.
*
* @example
* ```tsx
* <Accordion type="single" collapsible>
* <Accordion.Item value="item-1">
* <Accordion.Trigger level={2}>Is it accessible?</Accordion.Trigger>
* <Accordion.Content>Yes. It adheres to the WAI-ARIA design pattern.</Accordion.Content>
* </Accordion.Item>
* </Accordion>
* ```
*/
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
});
@@ -0,0 +1,37 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { useAccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
import type { AccordionContentProps } from '../types';
/**
* The collapsible content of an Accordion item.
*
* Uses a CSS Grid transition for smooth height animation.
* @example Basic usage:
*/
export const AccordionContent = ({ children, className }: AccordionContentProps) => {
const { value, isOpen } = useAccordionItemContext();
return (
<div
aria-hidden={!isOpen}
aria-labelledby={`accordion-trigger-${value}`}
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out',
isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
className
)}
id={`accordion-content-${value}`}
inert={!isOpen ? true : undefined}
role="region"
>
<div className="overflow-hidden">
<div className="pb-4 pt-0 text-sm leading-relaxed">
{children}
</div>
</div>
</div>
);
};
@@ -0,0 +1,40 @@
import { cn } from '@ermnvldmr/stl';
import React, { useMemo } from 'react';
import { useAccordionContext } from '../contexts/AccordionContext/AccordionContext';
import { AccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
import type { AccordionItemProps } from '../types';
/**
* An individual item within an Accordion.
*
* Provides context for the Trigger and Content sub-components.
* @example Basic usage:
*/
export const AccordionItem = ({
value,
children,
disabled = false,
className,
}: AccordionItemProps) => {
const { value: openValues } = useAccordionContext();
const isOpen = openValues.includes(value);
const contextValue = useMemo(
() => ({
value,
isOpen,
disabled,
}),
[value, isOpen, disabled]
);
return (
<AccordionItemContext.Provider value={contextValue}>
<div className={cn('border-b border-[var(--rb-outline)]/40', className)}>
{children}
</div>
</AccordionItemContext.Provider>
);
};
@@ -0,0 +1,73 @@
import { cn } from '@ermnvldmr/stl';
import { ChevronRight } from 'lucide-react';
import React, { useRef } from 'react';
import { useButton } from 'react-aria';
import { Header } from '../../Header/Header';
import { HStack } from '../../HStack/HStack';
import { useAccordionContext } from '../contexts/AccordionContext/AccordionContext';
import { useAccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
import type { AccordionTriggerProps } from '../types';
/**
* The interactive trigger for an Accordion item.
*
* Wraps the Header component and adds an expansion arrow.
* @example Basic usage:
* ```tsx
* <Accordion.Trigger>Click me</Accordion.Trigger>
* ```
*/
export const AccordionTrigger = ({
children,
className,
level = 3,
...headerProps
}: AccordionTriggerProps) => {
const { onValueChange } = useAccordionContext();
const { value, isOpen, disabled: isDisabled } = useAccordionItemContext();
const ref = useRef<HTMLButtonElement>(null);
const handleToggle = () => {
if (!isDisabled) {
onValueChange(value);
}
};
const { buttonProps } = useButton(
{
onPress: handleToggle,
isDisabled,
'aria-controls': `accordion-content-${value}`,
'aria-expanded': isOpen,
id: `accordion-trigger-${value}`,
type: 'button',
},
ref
);
return (
<Header level={level} {...headerProps}>
<button
{...buttonProps}
ref={ref}
className={cn(
'group flex w-full items-center justify-between py-4 text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--rb-ring)] focus-visible:ring-offset-2',
isDisabled && 'cursor-not-allowed opacity-50',
className
)}
>
<HStack align="center" className="w-full" gap={2}>
<ChevronRight
className={cn(
'size-5 shrink-0 transition-transform duration-200 text-[var(--rb-outline)]',
isOpen && 'rotate-90'
)}
/>
<span className="flex-1">{children}</span>
</HStack>
</button>
</Header>
);
};
@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react';
import type { AccordionContextValue } from '../../types';
/**
* Context for sharing accordion state with items.
*/
export const AccordionContext = createContext<AccordionContextValue | null>(null);
/**
* Hook to access the Accordion context.
* @example Using the context:
*/
export function useAccordionContext(): AccordionContextValue {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion sub-components must be used within an Accordion component.');
}
return context;
}
@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react';
import type { AccordionItemContextValue } from '../../types';
/**
* Context for sharing specific accordion item state.
*/
export const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
/**
* Hook to access the AccordionItem context.
* @example Using the context:
*/
export function useAccordionItemContext(): AccordionItemContextValue {
const context = useContext(AccordionItemContext);
if (!context) {
throw new Error('AccordionItem sub-components must be used within an AccordionItem component.');
}
return context;
}
@@ -0,0 +1,9 @@
export * from './Accordion';
export * from './types';
export * from './contexts/AccordionContext/AccordionContext';
export * from './contexts/AccordionItemContext/AccordionItemContext';
// Export sub-components individually for flexibility
export * from './AccordionItem/AccordionItem';
export * from './AccordionTrigger/AccordionTrigger';
export * from './AccordionContent/AccordionContent';
@@ -0,0 +1,88 @@
import type { HeaderProps } from '../Header/Header';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { ReactNode } from 'react';
/**
* Type of accordion behavior.
* - 'single': Only one item can be open at a time.
* - 'multiple': Multiple items can be open at a time.
*/
export type AccordionType = 'single' | 'multiple';
/**
* Context value for the root Accordion component.
*/
export interface AccordionContextValue {
/** Type of accordion behavior */
type: AccordionType;
/** Currently open values */
value: string[];
/** Callback when open values change */
onValueChange: (value: string) => void;
/** Whether the accordion is collapsible in single mode */
collapsible?: boolean;
}
/**
* Context value for an individual Accordion item.
*/
export interface AccordionItemContextValue {
/** Unique value for this item */
value: string;
/** Whether this item is currently open */
isOpen: boolean;
/** Whether this item is disabled */
disabled?: boolean;
}
/**
* Props for the root Accordion component.
*/
export interface AccordionProps extends ClassNameProps, TestIdProps {
/** Content of the accordion */
children: ReactNode;
/** Type of accordion behavior */
type?: AccordionType;
/** Controlled value of open items */
value?: string[];
/** Default value of open items (uncontrolled) */
defaultValue?: string[];
/** Callback when open values change */
onValueChange?: (value: string[]) => void;
/** Whether the accordion is collapsible in single mode */
collapsible?: boolean;
}
/**
* Props for the AccordionItem component.
*/
export interface AccordionItemProps extends ClassNameProps {
/** Unique value for this item */
value: string;
/** Content of the item (Trigger and Content) */
children: ReactNode;
/** Whether the item is disabled */
disabled?: boolean;
}
/**
* Props for the AccordionTrigger component.
*/
export interface AccordionTriggerProps extends ClassNameProps, Omit<HeaderProps, 'children' | 'level'> {
/** Content of the trigger (usually text) */
children: ReactNode;
/**
* The semantic header level (1-6).
* @default 3
*/
level?: 1 | 2 | 3 | 4 | 5 | 6;
}
/**
* Props for the AccordionContent component.
*/
export interface AccordionContentProps extends ClassNameProps {
/** Content to be revealed */
children: ReactNode;
}
@@ -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">
@@ -0,0 +1,291 @@
import { Star, Code, Layout, Send, Zap, Shield, Cpu, Gamepad2, Plane, BookOpen, Check, Gift } from 'lucide-react';
import React from 'react';
import { BentoGrid } from './BentoGrid';
import { Header } from '../Header/Header';
import { Text } from '../Text/Text';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof BentoGrid> = {
title: 'Components/BentoGrid',
component: BentoGrid,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof BentoGrid>;
export const Showcase: Story = {
render: () => (
<div className="w-full max-w-6xl p-4">
<BentoGrid>
<BentoGrid.ImageCard
alt="Developer workstation"
colSpan={2}
overlayDescription="A deep dive into modern web architecture and performance optimization."
overlayTitle="Featured Project"
rowSpan={2}
src="https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&q=80&w=2072"
/>
<BentoGrid.InfoCard
description="Built on top of Rsbuild and Rspack for near-instant compilation and ultra-fast HMR."
icon={<Zap className="text-rb-orange-500" size={24} />}
title="Lightning Fast"
/>
<BentoGrid.CTACard
buttonText="Get Started"
description="Connect with other developers and share your projects."
href="#"
title="Join the Community"
variant="primary"
/>
<BentoGrid.InfoCard
description="Strict TypeScript integration across the entire monorepo for maximum reliability."
footer={<div className="text-xs opacity-50">Strict Mode Enabled</div>}
icon={<Shield className="text-rb-green-500" size={24} />}
title="Type Safe"
/>
<BentoGrid.ImageCard
alt="Code on screen"
colSpan={2}
overlayTitle="Clean Code"
src="https://images.unsplash.com/photo-1461749280684-dccba630e2f6?auto=format&fit=crop&q=80&w=2069"
/>
<BentoGrid.InfoCard
description="Easily extend and customize the design system with our modular component library."
icon={<Cpu className="text-rb-blue-500" size={24} />}
title="Modular Architecture"
/>
<BentoGrid.CTACard
buttonText="View Docs"
description="Read the full documentation to learn more about the design system."
href="#"
title="Documentation"
/>
<BentoGrid.BaseCard className="bg-rb-tertiary-base/10 border-dashed" colSpan={4}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-rb-tertiary-base rounded-full text-rb-tertiary-text">
<Send size={20} />
</div>
<div>
<h4 className="font-bold text-rb-text">Subscribe to Newsletter</h4>
<p className="text-sm text-rb-muted-text">Stay updated with the latest releases and technical articles.</p>
</div>
</div>
<button className="px-6 py-2 bg-rb-text text-rb-base rounded-md font-medium">Subscribe</button>
</div>
</BentoGrid.BaseCard>
</BentoGrid>
</div>
),
};
export const LayoutPatterns: Story = {
render: () => (
<div className="w-full max-w-6xl p-4 flex flex-col gap-12">
<section>
<h3 className="mb-4 text-xl font-bold">Standard 4-Column Grid</h3>
<BentoGrid>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
</BentoGrid>
</section>
<section>
<h3 className="mb-4 text-xl font-bold">Mixed Spans (Hero Pattern)</h3>
<BentoGrid>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-primary-base/20 border-rb-primary-base" colSpan={2} rowSpan={2}>2x2 Hero</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
</BentoGrid>
</section>
<section>
<h3 className="mb-4 text-xl font-bold">Tall & Wide Pattern</h3>
<BentoGrid>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-secondary-base/20 border-rb-secondary-base" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-tertiary-base/20 border-rb-tertiary-base" rowSpan={2}>1x2 Tall</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-rb-color-neutral-100" colSpan={2}>2x1</BentoGrid.BaseCard>
</BentoGrid>
</section>
</div>
),
};
export const AllCardTypes: Story = {
render: () => (
<div className="w-full max-w-6xl p-4">
<BentoGrid>
<BentoGrid.InfoCard
description="Used for features or information. Can have an icon and a footer."
footer={<div className="text-xs font-mono">v1.0.0</div>}
icon={<Layout size={24} />}
title="Info Card"
/>
<BentoGrid.ImageCard
alt="Abstract image"
overlayDescription="Perfect for visual showcases with text overlays."
overlayTitle="Image Card"
src="https://images.unsplash.com/photo-1481487196290-c152efe083f5?auto=format&fit=crop&q=80&w=2048"
/>
<BentoGrid.CTACard
buttonText="Learn More"
description="Drives action with an outline button."
href="#"
title="CTA Card (Default)"
/>
<BentoGrid.CTACard
buttonText="Sign Up Now"
description="High prominence with a solid background."
href="#"
title="CTA Card (Primary)"
variant="primary"
/>
<BentoGrid.ListCard
colSpan={2}
defaultValue={['features']}
description="A specialized card for categorized lists, ideal for wishlists or feature matrices."
icon={<div className="p-2 bg-rb-blue-100 text-rb-blue-700 rounded-lg"><Star size={20} /></div>}
sections={[
{
value: 'features',
label: 'Features',
labelIcon: <Check size={18} />,
labelClassName: 'text-rb-green-600',
items: ['Responsive', 'Accessible', 'Themeable'],
}
]}
title="List Card"
/>
<BentoGrid.BaseCard className="flex flex-col gap-4">
<div className="p-2 bg-rb-color-yellow-100 text-rb-color-yellow-900 rounded-md w-fit">
<Code size={20} />
</div>
<h4 className="font-bold">Base Card (Custom)</h4>
<p className="text-sm">You can put any children here to create custom card types while maintaining the bento border and padding.</p>
</BentoGrid.BaseCard>
</BentoGrid>
</div>
),
};
export const WishlistVariant: Story = {
render: () => (
<div className="w-full max-w-6xl p-4">
<div className="mb-8">
<Header level={2}>My Wishlist</Header>
<Text className="text-rb-muted-text" size="l">Things I want and things I already have, organized by category.</Text>
</div>
<BentoGrid>
<BentoGrid.ListCard
colSpan={2}
defaultValue={['wanted']}
description="My essential list of gadgets and hardware I use for daily productivity and entertainment."
icon={
<div className="p-3 bg-rb-blue-100 text-rb-blue-700 rounded-xl">
<Gamepad2 size={24} />
</div>
}
rowSpan={2}
sections={[
{
value: 'wanted',
label: 'Wanted',
labelIcon: <Gift size={18} />,
labelClassName: 'text-rb-orange-600',
items: [
'Steam Deck OLED 1TB',
'Sony A7C II with 35mm lens',
'Keychron Mechanical Keyboard'
]
},
{
value: 'owned',
label: 'Already Owned',
labelIcon: <Check size={18} />,
labelClassName: 'text-rb-green-600',
items: [
'MacBook Pro M3 Max',
'Sony WH-1000XM5 Headphones',
'LG 27" 4K Monitor'
]
}
]}
title="Gadgets & Tech"
/>
<BentoGrid.ListCard
colSpan={2}
description="Places I want to visit and explore around the world."
icon={
<div className="p-3 bg-rb-green-100 text-rb-green-700 rounded-xl">
<Plane size={24} />
</div>
}
sections={[
{
value: 'wanted',
label: 'Bucket List',
labelIcon: <Gift size={18} />,
labelClassName: 'text-rb-orange-600',
items: [
'Kyoto, Japan (Cherry Blossom Season)',
'Reykjavik, Iceland',
'Swiss Alps'
]
},
{
value: 'owned',
label: 'Visited',
labelIcon: <Check size={18} />,
labelClassName: 'text-rb-green-600',
items: [
'Paris, France',
'Rome, Italy',
'London, UK'
]
}
]}
title="Travel Destinations"
type="single"
/>
<BentoGrid.ListCard
colSpan={2}
description="My reading backlog spanning technical books, sci-fi, and fantasy."
icon={
<div className="p-3 bg-rb-violet-100 text-rb-violet-700 rounded-xl">
<BookOpen size={24} />
</div>
}
sections={[
{
value: 'wanted',
label: 'Reading List',
labelIcon: <Gift size={18} />,
labelClassName: 'text-rb-orange-600',
items: [
'Designing Data-Intensive Applications',
'The Pragmatic Programmer',
'Dune Messiah'
]
}
]}
title="Books to Read"
type="single"
/>
</BentoGrid>
</div>
),
};
@@ -0,0 +1,121 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { BentoGrid } from './BentoGrid';
describe('BentoGrid', () => {
it('renders all card types correctly', () => {
render(
<BentoGrid>
<BentoGrid.BaseCard data-testid="base-card">Base Card Content</BentoGrid.BaseCard>
<BentoGrid.ImageCard
alt="Test Image"
overlayDescription="Image Description"
overlayTitle="Image Title"
src="test-image.jpg"
/>
<BentoGrid.InfoCard
description="Info Description"
footer={<span>Footer Content</span>}
title="Info Title"
/>
<BentoGrid.CTACard
buttonText="Click Me"
description="CTA Description"
href="/test"
title="CTA Title"
/>
<BentoGrid.ListCard
description="List Description"
sections={[
{
value: 's1',
label: 'Section 1',
items: ['Item A', 'Item B']
}
]}
title="List Title"
/>
</BentoGrid>
);
// BaseCard
expect(screen.getByTestId('base-card')).toBeInTheDocument();
expect(screen.getByText('Base Card Content')).toBeInTheDocument();
// ImageCard
expect(screen.getByAltText('Test Image')).toBeInTheDocument();
expect(screen.getByText('Image Title')).toBeInTheDocument();
expect(screen.getByText('Image Description')).toBeInTheDocument();
// InfoCard
expect(screen.getByText('Info Title')).toBeInTheDocument();
expect(screen.getByText('Info Description')).toBeInTheDocument();
expect(screen.getByText('Footer Content')).toBeInTheDocument();
// CTACard
expect(screen.getByText('CTA Title')).toBeInTheDocument();
expect(screen.getByText('CTA Description')).toBeInTheDocument();
expect(screen.getByText('Click Me')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Click Me/i })).toHaveAttribute('href', '/test');
// ListCard
expect(screen.getByText('List Title')).toBeInTheDocument();
expect(screen.getByText('List Description')).toBeInTheDocument();
expect(screen.getByText('Section 1')).toBeInTheDocument();
expect(screen.getByText('Item A')).toBeInTheDocument();
});
it('renders ListCard with object items (stable keys)', () => {
render(
<BentoGrid.ListCard
sections={[
{
value: 's1',
label: 'Section 1',
items: [
{ key: 'item-1', node: 'Stable Item 1' },
'Raw Item 2'
]
}
]}
title="List Title"
/>
);
expect(screen.getByText('Stable Item 1')).toBeInTheDocument();
expect(screen.getByText('Raw Item 2')).toBeInTheDocument();
});
it('applies span classes correctly', () => {
render(
<BentoGrid>
<BentoGrid.BaseCard colSpan={2} rowSpan={2}>
Spanning Card
</BentoGrid.BaseCard>
</BentoGrid>
);
const card = screen.getByText('Spanning Card');
expect(card).toHaveClass('md:col-span-2');
expect(card).toHaveClass('md:row-span-2');
});
it('applies primary variant to CTACard', () => {
render(
<BentoGrid>
<BentoGrid.CTACard
buttonText="Button"
description="Description"
href="/"
title="Primary CTA"
variant="primary"
/>
</BentoGrid>
);
const title = screen.getByText('Primary CTA');
const card = title.closest('.bg-rb-primary-base');
expect(card).toBeInTheDocument();
});
});
@@ -0,0 +1,56 @@
import { cn, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { BentoGridCTACard } from './BentoGridCTACard';
import { BentoGridImageCard } from './BentoGridImageCard';
import { BentoGridInfoCard } from './BentoGridInfoCard';
import { BentoGridListCard } from './BentoGridListCard';
import type { BentoGridProps } from './types';
const BentoGridComponent = forwardRef<HTMLDivElement, BentoGridProps>(function BentoGrid(
{ children, className, ...props },
ref
) {
return (
<div
{...props}
ref={ref}
className={cn(
'grid w-full grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4',
className
)}
>
{children}
</div>
);
});
/**
* A responsive Bento Grid system for creating structured, masonry-like layouts.
*
* @example
* ```tsx
* <BentoGrid>
* <BentoGrid.ImageCard
* colSpan={2}
* rowSpan={2}
* src="/image.jpg"
* alt="Showcase"
* overlayTitle="Featured"
* />
* <BentoGrid.InfoCard
* title="Details"
* description="Key features of the project."
* />
* </BentoGrid>
* ```
*/
export const BentoGrid = Object.assign(genericMemo(BentoGridComponent), {
BaseCard: BentoGridBaseCard,
ImageCard: BentoGridImageCard,
InfoCard: BentoGridInfoCard,
CTACard: BentoGridCTACard,
ListCard: BentoGridListCard,
});
@@ -0,0 +1,53 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import type { BentoCardBaseProps } from './types';
/**
* Utility to generate Tailwind grid span classes based on props.
* @example Generating classes:
* ```ts
* const classes = getSpanClasses({ colSpan: 2 });
* ```
*/
const getSpanClasses = ({ colSpan = 1, rowSpan = 1 }: BentoCardBaseProps) => {
const colSpans = {
1: 'md:col-span-1',
2: 'md:col-span-2',
3: 'md:col-span-3',
4: 'md:col-span-4',
};
const rowSpans = {
1: 'md:row-span-1',
2: 'md:row-span-2',
3: 'md:row-span-3',
};
return cn(colSpans[colSpan], rowSpans[rowSpan]);
};
/**
* Base card component that provides the foundational styling for all Bento cards.
* @example Basic usage:
*/
export const BentoGridBaseCard = ({
children,
className,
colSpan,
rowSpan,
...props
}: BentoCardBaseProps) => {
return (
<div
className={cn(
'relative overflow-hidden rounded-xl border border-rb-outline bg-rb-base p-6 shadow-sm transition-all hover:shadow-md',
getSpanClasses({ colSpan, rowSpan }),
className
)}
{...props}
>
{children}
</div>
);
};
@@ -0,0 +1,77 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Button } from '../Button/Button';
import { Header } from '../Header/Header';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridCTACardProps extends BentoCardBaseProps {
/** The title of the call to action */
title: string;
/** The description text */
description: string;
/** The text to display on the action button */
buttonText: string;
/** The destination URL for the action */
href: string;
/**
* Visual variant for the card.
* 'default' uses standard card styling.
* 'primary' uses a more prominent background.
*/
variant?: 'default' | 'primary';
}
/**
* A Bento card designed specifically to drive user action.
* @example Basic usage:
*/
export const BentoGridCTACard = ({
title,
description,
buttonText,
href,
variant = 'default',
className,
...baseProps
}: BentoGridCTACardProps) => {
return (
<BentoGridBaseCard
{...baseProps}
className={cn(
variant === 'primary' && 'bg-rb-primary-base border-rb-primary-base',
className
)}
>
<VStack className="h-full justify-between" gap={4}>
<VStack gap={2}>
<Header className={cn(variant === 'primary' && 'text-rb-primary-text')} level={3}>
{title}
</Header>
<Text
className={cn(variant === 'primary' ? 'text-rb-primary-text/80' : 'text-rb-muted-text')}
size="m"
>
{description}
</Text>
</VStack>
<div className="mt-auto">
<Button
className="w-full sm:w-auto"
href={href}
variant={variant === 'primary' ? 'solid' : 'outline'}
>
{buttonText}
</Button>
</div>
</VStack>
</BentoGridBaseCard>
);
};
@@ -0,0 +1,70 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Header } from '../Header/Header';
import { Image } from '../Image/Image';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridImageCardProps extends BentoCardBaseProps {
/** Source URL of the image */
src: string;
/** Alt text for the image */
alt: string;
/** Optional title to overlay on the image */
overlayTitle?: string;
/** Optional description to overlay on the image */
overlayDescription?: string;
}
/**
* A Bento card designed to showcase an image with optional textual overlays.
* @example Basic usage:
*/
export const BentoGridImageCard = ({
src,
alt,
overlayTitle,
overlayDescription,
className,
...baseProps
}: BentoGridImageCardProps) => {
const hasOverlay = !!overlayTitle || !!overlayDescription;
return (
<BentoGridBaseCard {...baseProps} className={cn('p-0', className)}>
<div className="relative h-full w-full">
<Image
alt={alt}
className="h-full w-full object-cover"
height="100%"
ratio="auto"
src={src}
/>
{hasOverlay && (
<div className="absolute inset-0 flex flex-col justify-end bg-linear-to-t from-black/80 via-black/40 to-transparent p-6 text-white">
<VStack gap={1}>
{overlayTitle && (
<Header className="text-white" level={3}>
{overlayTitle}
</Header>
)}
{overlayDescription && (
<Text className="text-white/90" size="s">
{overlayDescription}
</Text>
)}
</VStack>
</div>
)}
</div>
</BentoGridBaseCard>
);
};
@@ -0,0 +1,47 @@
import React from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Header } from '../Header/Header';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridInfoCardProps extends BentoCardBaseProps {
/** The title of the card */
title: string;
/** The description text */
description?: string;
/** Optional icon or graphic to display at the top */
icon?: React.ReactNode;
/** Optional footer content */
footer?: React.ReactNode;
}
/**
* A versatile Bento card for text-heavy content or feature highlights.
* @example Basic usage:
*/
export const BentoGridInfoCard = ({
title,
description,
icon,
footer,
...baseProps
}: BentoGridInfoCardProps) => {
return (
<BentoGridBaseCard {...baseProps}>
<VStack className="h-full justify-between" gap={4}>
<VStack gap={2}>
{icon && <div className="text-rb-primary-text">{icon}</div>}
<Header level={3}>{title}</Header>
{description && <Text className="text-rb-muted-text" size="m">{description}</Text>}
</VStack>
{footer && <div className="mt-auto">{footer}</div>}
</VStack>
</BentoGridBaseCard>
);
};
@@ -0,0 +1,126 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { Accordion } from '../Accordion';
import { Header } from '../Header/Header';
import { HStack } from '../HStack/HStack';
import { List } from '../List';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridListCardSection {
/** Unique identifier for the accordion section */
value: string;
/** Label text for the accordion trigger */
label: string;
/** Optional icon next to the label */
labelIcon?: React.ReactNode;
/** Optional class name for the label text color (e.g. 'text-rb-orange-600') */
labelClassName?: string;
/** List of items to display when expanded. Can be raw nodes or objects with keys. */
items: (React.ReactNode | { key: string; node: React.ReactNode })[];
}
/**
*
*/
export interface BentoGridListCardProps extends BentoCardBaseProps {
/** Title of the card */
title: string;
/** Description text */
description?: string;
/** Optional icon next to the title. For colored backgrounds, wrap the icon in a styled div. */
icon?: React.ReactNode;
/** Sections containing lists of items, rendered as Accordions */
sections: BentoGridListCardSection[];
/** Which sections are expanded by default (uses section value) */
defaultValue?: string[];
/** Whether multiple sections can be open at once */
type?: 'single' | 'multiple';
}
/**
* A Bento card designed for categorized lists of items using Accordions.
*
* @example
* ```tsx
* <BentoGrid.ListCard
* title="My List"
* sections={[
* { value: 's1', label: 'Section 1', items: ['Item 1', 'Item 2'] }
* ]}
* />
* ```
*/
export const BentoGridListCard = ({
title,
description,
icon,
sections,
defaultValue,
type = 'multiple',
...baseProps
}: BentoGridListCardProps) => {
return (
<BentoGridBaseCard {...baseProps} className={cn('flex flex-col', baseProps.className)}>
<VStack className="h-full" gap={4}>
<HStack align="center" gap={3}>
{icon && <div>{icon}</div>}
<Header level={3}>{title}</Header>
</HStack>
{description && (
<Text className="text-rb-muted-text">
{description}
</Text>
)}
<div className="flex-grow w-full">
<Accordion collapsible defaultValue={defaultValue} type={type}>
{sections.map((section) => (
<Accordion.Item key={section.value} value={section.value}>
<Accordion.Trigger className={section.labelClassName}>
<HStack align="center" gap={2}>
{section.labelIcon}
<Text size="l" type="body">{section.label}</Text>
</HStack>
</Accordion.Trigger>
<Accordion.Content>
<List>
{section.items.map((item, index) => {
const content = (() => {
if (item && typeof item === 'object' && 'node' in item) {
return item.node;
}
return item;
})();
const itemKey =
item && typeof item === 'object' && 'key' in item
? item.key
: typeof item === 'string'
? item
: index;
return (
<List.Item key={itemKey}>
<Text size="m">{content}</Text>
</List.Item>
);
})}
</List>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion>
</div>
</VStack>
</BentoGridBaseCard>
);
};
@@ -0,0 +1,7 @@
export * from './BentoGrid';
export * from './types';
export * from './BentoGridBaseCard';
export * from './BentoGridImageCard';
export * from './BentoGridInfoCard';
export * from './BentoGridCTACard';
export * from './BentoGridListCard';
@@ -0,0 +1,39 @@
import type React from 'react';
/**
* Common props for all BentoGrid card variations.
*/
export interface BentoCardBaseProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Number of columns to span on medium screens and up.
* @default 1
*/
colSpan?: 1 | 2 | 3 | 4;
/**
* Number of rows to span on medium screens and up.
* @default 1
*/
rowSpan?: 1 | 2 | 3;
/**
* Additional CSS classes.
*/
className?: string;
/**
* The content of the card.
*/
children?: React.ReactNode;
}
/**
* Props for the root BentoGrid component.
*/
export interface BentoGridProps {
/**
* The cards to display in the grid.
*/
children: React.ReactNode;
/**
* Additional CSS classes.
*/
className?: string;
}
@@ -33,6 +33,18 @@ export const Default: Story = {
),
};
export const Interactive: Story = {
render: () => (
<Breadcrumbs>
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="arrow" />
<Breadcrumbs.Item onClick={() => alert('Clicked!')}>Click Me</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="arrow" />
<Breadcrumbs.Item isCurrent>Current Page</Breadcrumbs.Item>
</Breadcrumbs>
),
};
export const WithArrows: Story = {
render: () => (
<Breadcrumbs>
@@ -1,7 +1,7 @@
import React from 'react';
import { Breadcrumbs } from './Breadcrumbs';
import { render, screen } from '../../lib/testing';
import { render, screen, fireEvent } from '../../lib/testing';
import { Link } from '../Link/Link';
describe('components/Breadcrumbs', () => {
@@ -12,14 +12,58 @@ describe('components/Breadcrumbs', () => {
<Link href="/">Home</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="slash" />
<Breadcrumbs.Item>Current</Breadcrumbs.Item>
<Breadcrumbs.Item isCurrent>Current</Breadcrumbs.Item>
</Breadcrumbs>
);
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Breadcrumb');
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
expect(screen.getByText('/')).toBeInTheDocument();
expect(screen.getByText('Current')).toBeInTheDocument();
const currentItem = screen.getByText('Current').parentElement;
expect(currentItem).toHaveAttribute('aria-current', 'page');
});
it('supports isCurrent state', () => {
render(
<Breadcrumbs>
<Breadcrumbs.Item isCurrent>Current Page</Breadcrumbs.Item>
</Breadcrumbs>
);
const item = screen.getByText('Current Page').parentElement;
expect(item).toHaveAttribute('aria-current', 'page');
});
it('supports href directly on Item', () => {
render(
<Breadcrumbs>
<Breadcrumbs.Item href="/test-url">Test Link</Breadcrumbs.Item>
</Breadcrumbs>
);
const link = screen.getByRole('link', { name: 'Test Link' });
expect(link).toHaveAttribute('href', '/en/test-url');
});
it('supports onClick on Item', () => {
const handleClick = vi.fn();
render(
<Breadcrumbs>
<Breadcrumbs.Item onClick={handleClick}>Clickable</Breadcrumbs.Item>
</Breadcrumbs>
);
const button = screen.getByRole('button', { name: 'Clickable' });
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('renders as a non-submit button when onClick is provided', () => {
render(
<Breadcrumbs>
<Breadcrumbs.Item onClick={vi.fn()}>Clickable</Breadcrumbs.Item>
</Breadcrumbs>
);
const button = screen.getByRole('button', { name: 'Clickable' });
expect(button).toHaveAttribute('type', 'button');
});
it('supports different separator variants', () => {
@@ -1,3 +1,4 @@
import { localePath } from '@ermnvldmr/i18n';
import { cn } from '@ermnvldmr/stl';
import { ChevronRight } from 'lucide-react';
import React, { memo } from 'react';
@@ -29,20 +30,58 @@ const BreadcrumbsRoot = memo(function Breadcrumbs({ children, ...props }: Breadc
);
});
/**
* Props for the individual Breadcrumbs item.
*/
export interface BreadcrumbsItemProps extends TestIdProps {
/** Content to be rendered inside the item */
children: React.ReactNode;
/** Optional className for the list item */
className?: string;
/** Whether this is the current page */
isCurrent?: boolean;
/** Optional href for the item (renders as a link) */
href?: string;
/** Optional click handler for the item */
onClick?: React.MouseEventHandler<HTMLElement>;
}
/**
* Individual step in the breadcrumb trail.
*/
const BreadcrumbsItem = memo(function BreadcrumbsItem({
children,
className,
isCurrent,
href,
onClick,
'data-testid': testId,
}: {
children: React.ReactNode;
className?: string;
} & TestIdProps) {
}: BreadcrumbsItemProps) {
const resolvedHref = localePath(href);
const isClickable = !!(resolvedHref ?? onClick);
return (
<li className={className} data-testid={testId}>
<Text color="muted" size="m" type="label">
<li
aria-current={isCurrent ? 'page' : undefined}
className={cn(
'group flex items-center transition-all duration-200',
!isCurrent && isClickable && 'cursor-pointer',
className
)}
data-testid={testId}
>
<Text
as={resolvedHref ? 'a' : onClick ? 'button' : 'span'}
className={cn(
'transition-all duration-200',
!isCurrent && 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4'
)}
color={isCurrent ? 'default' : 'muted'}
href={resolvedHref}
size="m"
type="label"
onClick={onClick}
>
{children}
</Text>
</li>
@@ -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');
});
});
Loaded 100 of 566 files, more files were not shown because too many files have changed in this diff. Show more