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
176 changed files with 10460 additions and 1280 deletions

No files matched your search

+2 -2
View File
@@ -320,7 +320,7 @@ Use GitHub-flavored alerts for emphasis.
## 6. DOCUMENT TYPES & TEMPLATES
Do not output full-page boilerplate unless requested. Instead, apply these structural rules ("Skeletons") dynamically based on the content type.
This is also serves as the fixed categories which should populate every project's docs: Getting Started, Guides, Recipes, Concepts, Troubleshooting.
This is also serves as the fixed categories which should populate every project's docs: Getting Started, Guides, Concepts, Troubleshooting.
### 6.1 General Skeleton (All Docs)
@@ -349,7 +349,7 @@ This is also serves as the fixed categories which should populate every project'
3. **Procedure:** Strict numbered steps (or `{{% steps %}}`).
4. **Result Control:** How to verify success.
### 6.4 How-to / Recipe (Task-Oriented)
### 6.4 How-to / Guide (Task-Oriented)
- **Goal:** Solve a specific problem.
- **Structure:**
@@ -6,6 +6,7 @@ on:
paths:
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/packages-ui-next-required.yml'
- 'pnpm-lock.yaml'
@@ -64,6 +65,8 @@ jobs:
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build Storybook config
run: pnpm --filter @ermnvldmr/storybook-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Rsbuild Config
uses: actions/upload-artifact@v4
with:
@@ -76,6 +79,12 @@ jobs:
name: storybook-config-dist
path: packages/storybook-config/dist
retention-days: 1
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
retention-days: 1
lint-ui:
name: Lint - UI
@@ -103,6 +112,11 @@ jobs:
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Lint UI package
run: pnpm --filter @ermnvldmr/ui lint
@@ -132,6 +146,11 @@ jobs:
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Typecheck UI package
run: pnpm --filter @ermnvldmr/ui typecheck
@@ -161,6 +180,11 @@ jobs:
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Test UI package
run: pnpm --filter @ermnvldmr/ui test:coverage
- name: Upload coverage to Codecov
@@ -197,6 +221,11 @@ jobs:
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Build UI Storybook
run: pnpm --filter @ermnvldmr/ui build-storybook
@@ -226,6 +255,11 @@ jobs:
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Install Playwright browsers
run: pnpm --filter @ermnvldmr/ui exec playwright install --with-deps chromium
- name: Build UI Storybook
@@ -266,5 +300,10 @@ jobs:
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Build UI package
run: pnpm --filter @ermnvldmr/ui build
@@ -6,6 +6,7 @@ on:
paths:
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/packages-ui-pr-required.yml'
- 'pnpm-lock.yaml'
@@ -64,6 +65,8 @@ jobs:
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build Storybook config
run: pnpm --filter @ermnvldmr/storybook-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Rsbuild Config
uses: actions/upload-artifact@v4
with:
@@ -76,6 +79,12 @@ jobs:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
retention-days: 1
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
retention-days: 1
lint-ui:
name: Lint - UI
@@ -103,6 +112,11 @@ jobs:
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Lint UI package
run: pnpm --filter @ermnvldmr/ui lint
@@ -132,6 +146,11 @@ jobs:
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Typecheck UI package
run: pnpm --filter @ermnvldmr/ui typecheck
@@ -161,6 +180,11 @@ jobs:
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Test UI package
run: pnpm --filter @ermnvldmr/ui test:coverage
- name: Upload coverage to Codecov
@@ -197,6 +221,11 @@ jobs:
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Build UI Storybook
run: pnpm --filter @ermnvldmr/ui build-storybook
- name: Upload Storybook build
@@ -232,6 +261,11 @@ jobs:
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Install Playwright browsers
run: pnpm --filter @ermnvldmr/ui exec playwright install --with-deps chromium
- name: Download Storybook build
@@ -275,5 +309,10 @@ jobs:
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Build UI package
run: pnpm --filter @ermnvldmr/ui build
@@ -7,6 +7,7 @@ on:
- 'services/static/**'
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/services-static-next-required.yml'
- 'pnpm-lock.yaml'
@@ -63,12 +64,20 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build Rsbuild config
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Config Artifacts
uses: actions/upload-artifact@v4
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
retention-days: 1
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
retention-days: 1
lint-static:
name: Lint - Static
@@ -91,6 +100,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Lint static service
run: pnpm --filter @ermnvldmr/static lint
@@ -115,6 +129,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Typecheck static service
run: pnpm --filter @ermnvldmr/static typecheck
@@ -139,6 +158,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Build static service
run: pnpm --filter @ermnvldmr/static build
- name: Upload build artifacts
@@ -7,6 +7,7 @@ on:
- 'services/static/**'
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/services-static-pr-required.yml'
- 'pnpm-lock.yaml'
@@ -63,12 +64,20 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build Rsbuild config
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Config Artifacts
uses: actions/upload-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
retention-days: 1
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
retention-days: 1
lint-static:
name: Lint - Static
@@ -91,6 +100,11 @@ jobs:
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Lint static service
run: pnpm --filter @ermnvldmr/static lint
@@ -115,6 +129,11 @@ jobs:
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Typecheck static service
run: pnpm --filter @ermnvldmr/static typecheck
@@ -139,6 +158,11 @@ jobs:
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Build static service
run: pnpm --filter @ermnvldmr/static build
- name: Upload build artifacts
@@ -7,6 +7,7 @@ on:
- 'services/www/**'
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/services-www-next-required.yml'
- 'pnpm-lock.yaml'
@@ -77,6 +78,14 @@ jobs:
name: storybook-config-dist
path: packages/storybook-config/dist
retention-days: 1
- name: Build i18n package
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
retention-days: 1
lint-www:
name: Lint - WWW
@@ -99,6 +108,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -128,6 +142,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -157,6 +176,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -192,6 +216,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -228,6 +257,11 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -271,13 +305,26 @@ jobs:
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist
path: packages/storybook-config/dist
- name: Build WWW service
run: pnpm --filter @ermnvldmr/www build
- name: Build WWW service (all locales)
run: pnpm --filter @ermnvldmr/www build:all
env:
RSDOCTOR: 'true'
- name: Upload Rsdoctor report
uses: actions/upload-artifact@v4
with:
name: rsdoctor-report
path: services/www/rsdoctor/
retention-days: 30
release-docker:
name: Release - Docker Image
+49 -2
View File
@@ -7,6 +7,7 @@ on:
- 'services/www/**'
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/services-www-pr-required.yml'
- 'pnpm-lock.yaml'
@@ -77,6 +78,14 @@ jobs:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
retention-days: 1
- name: Build i18n package
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
retention-days: 1
lint-www:
name: Lint - WWW
@@ -99,6 +108,11 @@ jobs:
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -128,6 +142,11 @@ jobs:
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -157,6 +176,11 @@ jobs:
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -192,6 +216,11 @@ jobs:
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -228,6 +257,11 @@ jobs:
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
@@ -271,10 +305,23 @@ jobs:
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Build WWW service
run: pnpm --filter @ermnvldmr/www build
- name: Build WWW service (all locales)
run: pnpm --filter @ermnvldmr/www build:all
env:
RSDOCTOR: 'true'
- name: Upload Rsdoctor report
uses: actions/upload-artifact@v4
with:
name: rsdoctor-report-${{ github.run_id }}
path: services/www/rsdoctor/
retention-days: 7
@@ -54,6 +54,8 @@ jobs:
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build Storybook config
run: pnpm --filter @ermnvldmr/storybook-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Config Artifacts
uses: actions/upload-artifact@v4
with:
@@ -61,6 +63,7 @@ jobs:
path: |
packages/rsbuild-config/dist
packages/storybook-config/dist
packages/i18n/dist
retention-days: 1
lint-www:
@@ -60,6 +60,8 @@ jobs:
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build Storybook config
run: pnpm --filter @ermnvldmr/storybook-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Upload Config Artifacts
uses: actions/upload-artifact@v4
with:
@@ -67,6 +69,7 @@ jobs:
path: |
packages/rsbuild-config/dist
packages/storybook-config/dist
packages/i18n/dist
retention-days: 1
lint-www:
+1
View File
@@ -30,3 +30,4 @@ playwright-report/
# agents plans and designs
docs/plans/
docs/superpowers/
+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
-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",
+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": {
@@ -4,6 +4,7 @@ import { pluginTypeCheck } from '@rsbuild/plugin-type-check';
import { pluginDts } from 'rsbuild-plugin-dts';
import { mergeConfig } from '../utils/merge.js';
import { ssgPlugin } from './ssg.js';
/**
* Defines a standard Rsbuild configuration for services (apps).
@@ -21,7 +22,7 @@ import { mergeConfig } from '../utils/merge.js';
*/
export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
const defaultConfig: RsbuildConfig = {
plugins: [pluginReact(), pluginTypeCheck()],
plugins: [pluginReact(), pluginTypeCheck(), ssgPlugin()],
output: {
distPath: {
font: 'static/font',
@@ -35,28 +36,6 @@ export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
meta: {
description: 'Thoughts and creations of mine',
},
tags: [
{
tag: 'link',
attrs: {
rel: 'preload',
as: 'font',
type: 'font/woff2',
href: '/static/font/lato-400.woff2',
crossorigin: true,
},
},
{
tag: 'link',
attrs: {
rel: 'preload',
as: 'font',
type: 'font/woff2',
href: '/static/font/lato-700.woff2',
crossorigin: true,
},
},
],
},
};
+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';
+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
*
@@ -39,9 +39,8 @@ export function useLocalStorage<T>(
const getSnapshot = useCallback(() => manager.get(), [manager]);
const value = useSyncExternalStore(subscribe, getSnapshot, () => {
// During SSR, we can't access localStorage.
// However, createLocalStorage is designed to throw if accessed during SSR.
// This hook is intended to be used in client-side components.
// SSR snapshot: createLocalStorage returns a no-op manager in Node context,
// so manager.get() safely returns defaultValue without accessing localStorage.
return manager.get();
});
@@ -44,9 +44,15 @@ describe('lib/window/localStorage', () => {
for (const key in mockStorage) delete mockStorage[key];
});
it('should throw if window is undefined', () => {
it('returns a no-op manager when window is undefined (SSR/SSG context)', () => {
vi.stubGlobal('window', undefined);
expect(() => createLocalStorage(KEY, DEFAULT_VALUE)).toThrow();
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
expect(manager.key).toBe(KEY);
expect(manager.get()).toEqual(DEFAULT_VALUE);
expect(() => manager.set({ foo: 'x' })).not.toThrow();
expect(() => manager.remove()).not.toThrow();
// get() still returns defaultValue after set (no-op in SSR)
expect(manager.get()).toEqual(DEFAULT_VALUE);
});
it('should get default value if empty', () => {
+12 -7
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> = {
+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"
}
}
+24
View File
@@ -22,3 +22,27 @@ Object.defineProperty(window, 'matchMedia', {
dispatchEvent: vi.fn(),
})),
});
// jsdom sometimes does not expose localStorage.clear(); provide a functional polyfill
if (typeof window.localStorage.clear !== 'function') {
const store = new Map<string, string>();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
getItem: (key: string): string | null => store.get(key) ?? null,
setItem: (key: string, value: string): void => {
store.set(key, value);
},
removeItem: (key: string): void => {
store.delete(key);
},
clear: (): void => {
store.clear();
},
key: (index: number): string | null => [...store.keys()][index] ?? null,
get length(): number {
return store.size;
},
},
});
}
+2 -1
View File
@@ -41,6 +41,7 @@
"clean": "rm -rf dist coverage storybook-static"
},
"dependencies": {
"@ermnvldmr/i18n": "workspace:*",
"@ermnvldmr/stl": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.12",
@@ -82,7 +83,7 @@
"jsdom": "^28.0.0",
"playwright": "^1.57.0",
"postcss": "^8.5.6",
"storybook": "^8.6.15",
"storybook": "^8.6.17",
"storybook-react-rsbuild": "^1.0.3",
"typescript": "^5.8.3",
"vitest": "^4.0.18",
@@ -0,0 +1,112 @@
import React from 'react';
import { Accordion } from './Accordion';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Accordion> = {
title: 'Components/Accordion',
component: Accordion,
tags: ['autodocs'],
argTypes: {
type: {
control: 'select',
options: ['single', 'multiple'],
},
collapsible: {
control: 'boolean',
},
},
};
export default meta;
type Story = StoryObj<typeof Accordion>;
export const Default: Story = {
args: {
type: 'single',
collapsible: true,
},
render: (args) => (
<Accordion {...args}>
<Accordion.Item value="item-1">
<Accordion.Trigger level={2}>Is it accessible?</Accordion.Trigger>
<Accordion.Content>
Yes. It adheres to the WAI-ARIA design pattern.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger level={2}>Is it styled?</Accordion.Trigger>
<Accordion.Content>
Yes. It comes with default styles that matches the design system.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-3">
<Accordion.Trigger level={2}>Is it animated?</Accordion.Trigger>
<Accordion.Content>
Yes. It uses CSS Grid transitions for smooth height animation.
</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
export const Multiple: Story = {
args: {
type: 'multiple',
},
render: (args) => (
<Accordion {...args}>
<Accordion.Item value="item-1">
<Accordion.Trigger level={3}>Section One</Accordion.Trigger>
<Accordion.Content>
This section can be open alongside others.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger level={3}>Section Two</Accordion.Trigger>
<Accordion.Content>
You can open as many sections as you want in multiple mode.
</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
export const DifferentHeaderLevels: Story = {
render: () => (
<Accordion type="single">
<Accordion.Item value="h1">
<Accordion.Trigger level={1}>Header Level 1</Accordion.Trigger>
<Accordion.Content>Content for H1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="h2">
<Accordion.Trigger level={2}>Header Level 2</Accordion.Trigger>
<Accordion.Content>Content for H2</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="h3">
<Accordion.Trigger level={3}>Header Level 3</Accordion.Trigger>
<Accordion.Content>Content for H3</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="h4">
<Accordion.Trigger level={4}>Header Level 4</Accordion.Trigger>
<Accordion.Content>Content for H4</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
export const Disabled: Story = {
render: () => (
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger level={3}>Enabled Item</Accordion.Trigger>
<Accordion.Content>This one works fine.</Accordion.Content>
</Accordion.Item>
<Accordion.Item disabled value="item-2">
<Accordion.Trigger level={3}>Disabled Item</Accordion.Trigger>
<Accordion.Content>You should not see this.</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
@@ -0,0 +1,221 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { vi } from 'vitest';
import { Accordion } from './Accordion';
describe('Accordion', () => {
it('renders correctly with all sub-components', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger level={2}>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
expect(screen.getByText('Trigger 1')).toBeInTheDocument();
expect(screen.getByText('Content 1')).toBeInTheDocument();
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
});
it('toggles content on click in single mode', async () => {
const user = userEvent.setup();
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const trigger1 = screen.getByText('Trigger 1');
const trigger2 = screen.getByText('Trigger 2');
await user.click(trigger1);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'false');
await user.click(trigger2);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('allows multiple items to be open in multiple mode', async () => {
const user = userEvent.setup();
render(
<Accordion type="multiple">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
await user.click(screen.getByText('Trigger 1'));
await user.click(screen.getByText('Trigger 2'));
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('respects the collapsible prop in single mode', async () => {
const user = userEvent.setup();
render(
<Accordion collapsible={false} type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const trigger = screen.getByText('Trigger 1');
// Open it
await user.click(trigger);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
// Try to close it (should stay open because collapsible={false})
await user.click(trigger);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
});
it('does not toggle when disabled', async () => {
const user = userEvent.setup();
render(
<Accordion type="single">
<Accordion.Item disabled value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const trigger = screen.getByText('Trigger 1');
await user.click(trigger);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button')).toBeDisabled();
});
it('works as a controlled component', async () => {
const user = userEvent.setup();
const TestComponent = () => {
const [value, setValue] = React.useState<string[]>(['item-1']);
return (
<Accordion type="single" value={value} onValueChange={setValue}>
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
};
render(<TestComponent />);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
await user.click(screen.getByText('Trigger 2'));
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('works as an uncontrolled component with defaultValue', () => {
render(
<Accordion defaultValue={['item-2']} type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('renders with valid HTML structure (heading containing button)', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger level={3}>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const heading = screen.getByRole('heading', { level: 3 });
const button = screen.getByRole('button');
expect(heading).toContainElement(button);
});
it('applies accessibility attributes to content when closed', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const content = screen.getByText('Content 1').closest('[role="region"]');
expect(content).toHaveAttribute('aria-hidden', 'true');
expect(content).toHaveAttribute('inert');
});
it('removes accessibility hiding attributes when open', async () => {
const user = userEvent.setup();
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
await user.click(screen.getByText('Trigger 1'));
const content = screen.getByText('Content 1').closest('[role="region"]');
expect(content).toHaveAttribute('aria-hidden', 'false');
expect(content).not.toHaveAttribute('inert');
});
it('throws error when sub-components are used outside of Accordion', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<Accordion.Trigger>Trigger</Accordion.Trigger>)).toThrow(
'Accordion sub-components must be used within an Accordion component.'
);
expect(() => render(<Accordion.Item value="1">Item</Accordion.Item>)).toThrow(
'Accordion sub-components must be used within an Accordion component.'
);
expect(() => render(<Accordion.Content>Content</Accordion.Content>)).toThrow(
'AccordionItem sub-components must be used within an AccordionItem component.'
);
consoleSpy.mockRestore();
});
});
@@ -0,0 +1,95 @@
import { cn } from '@ermnvldmr/stl';
import React, { useState, useCallback, useMemo } from 'react';
import { AccordionContent } from './AccordionContent/AccordionContent';
import { AccordionItem } from './AccordionItem/AccordionItem';
import { AccordionTrigger } from './AccordionTrigger/AccordionTrigger';
import { AccordionContext } from './contexts/AccordionContext/AccordionContext';
import type { AccordionProps } from './types';
/**
* An Accordion component that allows users to reveal and hide content sections.
*
* Built with a compound component pattern for maximum flexibility.
* @example Basic usage:
*/
const AccordionRoot = ({
type = 'single',
value: controlledValue,
defaultValue = [],
onValueChange,
collapsible = true,
children,
className,
'data-testid': testId,
}: AccordionProps) => {
const [internalValue, setInternalValue] = useState<string[]>(defaultValue);
const isControlled = controlledValue !== undefined;
const currentValues = isControlled ? controlledValue : internalValue;
const handleValueChange = useCallback(
(itemValue: string) => {
let nextValues: string[];
if (type === 'single') {
const isCurrentOpen = currentValues.includes(itemValue);
if (isCurrentOpen && collapsible) {
nextValues = [];
} else if (isCurrentOpen && !collapsible) {
nextValues = [itemValue];
} else {
nextValues = [itemValue];
}
} else {
nextValues = currentValues.includes(itemValue)
? currentValues.filter((v) => v !== itemValue)
: [...currentValues, itemValue];
}
if (!isControlled) {
setInternalValue(nextValues);
}
onValueChange?.(nextValues);
},
[type, currentValues, collapsible, isControlled, onValueChange]
);
const contextValue = useMemo(
() => ({
type,
value: currentValues,
onValueChange: handleValueChange,
collapsible,
}),
[type, currentValues, handleValueChange, collapsible]
);
return (
<AccordionContext.Provider value={contextValue}>
<div className={cn('w-full', className)} data-testid={testId}>
{children}
</div>
</AccordionContext.Provider>
);
};
/**
* A collapsible accordion component system.
*
* @example
* ```tsx
* <Accordion type="single" collapsible>
* <Accordion.Item value="item-1">
* <Accordion.Trigger level={2}>Is it accessible?</Accordion.Trigger>
* <Accordion.Content>Yes. It adheres to the WAI-ARIA design pattern.</Accordion.Content>
* </Accordion.Item>
* </Accordion>
* ```
*/
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
});
@@ -0,0 +1,37 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { useAccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
import type { AccordionContentProps } from '../types';
/**
* The collapsible content of an Accordion item.
*
* Uses a CSS Grid transition for smooth height animation.
* @example Basic usage:
*/
export const AccordionContent = ({ children, className }: AccordionContentProps) => {
const { value, isOpen } = useAccordionItemContext();
return (
<div
aria-hidden={!isOpen}
aria-labelledby={`accordion-trigger-${value}`}
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out',
isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
className
)}
id={`accordion-content-${value}`}
inert={!isOpen ? true : undefined}
role="region"
>
<div className="overflow-hidden">
<div className="pb-4 pt-0 text-sm leading-relaxed">
{children}
</div>
</div>
</div>
);
};
@@ -0,0 +1,40 @@
import { cn } from '@ermnvldmr/stl';
import React, { useMemo } from 'react';
import { useAccordionContext } from '../contexts/AccordionContext/AccordionContext';
import { AccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
import type { AccordionItemProps } from '../types';
/**
* An individual item within an Accordion.
*
* Provides context for the Trigger and Content sub-components.
* @example Basic usage:
*/
export const AccordionItem = ({
value,
children,
disabled = false,
className,
}: AccordionItemProps) => {
const { value: openValues } = useAccordionContext();
const isOpen = openValues.includes(value);
const contextValue = useMemo(
() => ({
value,
isOpen,
disabled,
}),
[value, isOpen, disabled]
);
return (
<AccordionItemContext.Provider value={contextValue}>
<div className={cn('border-b border-[var(--rb-outline)]/40', className)}>
{children}
</div>
</AccordionItemContext.Provider>
);
};
@@ -0,0 +1,73 @@
import { cn } from '@ermnvldmr/stl';
import { ChevronRight } from 'lucide-react';
import React, { useRef } from 'react';
import { useButton } from 'react-aria';
import { Header } from '../../Header/Header';
import { HStack } from '../../HStack/HStack';
import { useAccordionContext } from '../contexts/AccordionContext/AccordionContext';
import { useAccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
import type { AccordionTriggerProps } from '../types';
/**
* The interactive trigger for an Accordion item.
*
* Wraps the Header component and adds an expansion arrow.
* @example Basic usage:
* ```tsx
* <Accordion.Trigger>Click me</Accordion.Trigger>
* ```
*/
export const AccordionTrigger = ({
children,
className,
level = 3,
...headerProps
}: AccordionTriggerProps) => {
const { onValueChange } = useAccordionContext();
const { value, isOpen, disabled: isDisabled } = useAccordionItemContext();
const ref = useRef<HTMLButtonElement>(null);
const handleToggle = () => {
if (!isDisabled) {
onValueChange(value);
}
};
const { buttonProps } = useButton(
{
onPress: handleToggle,
isDisabled,
'aria-controls': `accordion-content-${value}`,
'aria-expanded': isOpen,
id: `accordion-trigger-${value}`,
type: 'button',
},
ref
);
return (
<Header level={level} {...headerProps}>
<button
{...buttonProps}
ref={ref}
className={cn(
'group flex w-full items-center justify-between py-4 text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--rb-ring)] focus-visible:ring-offset-2',
isDisabled && 'cursor-not-allowed opacity-50',
className
)}
>
<HStack align="center" className="w-full" gap={2}>
<ChevronRight
className={cn(
'size-5 shrink-0 transition-transform duration-200 text-[var(--rb-outline)]',
isOpen && 'rotate-90'
)}
/>
<span className="flex-1">{children}</span>
</HStack>
</button>
</Header>
);
};
@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react';
import type { AccordionContextValue } from '../../types';
/**
* Context for sharing accordion state with items.
*/
export const AccordionContext = createContext<AccordionContextValue | null>(null);
/**
* Hook to access the Accordion context.
* @example Using the context:
*/
export function useAccordionContext(): AccordionContextValue {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion sub-components must be used within an Accordion component.');
}
return context;
}
@@ -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;
}
@@ -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;
}
@@ -41,7 +41,7 @@ describe('components/Breadcrumbs', () => {
</Breadcrumbs>
);
const link = screen.getByRole('link', { name: 'Test Link' });
expect(link).toHaveAttribute('href', '/test-url');
expect(link).toHaveAttribute('href', '/en/test-url');
});
it('supports onClick on Item', () => {
@@ -1,3 +1,4 @@
import { localePath } from '@ermnvldmr/i18n';
import { cn } from '@ermnvldmr/stl';
import { ChevronRight } from 'lucide-react';
import React, { memo } from 'react';
@@ -56,7 +57,8 @@ const BreadcrumbsItem = memo(function BreadcrumbsItem({
onClick,
'data-testid': testId,
}: BreadcrumbsItemProps) {
const isClickable = !!(href ?? onClick);
const resolvedHref = localePath(href);
const isClickable = !!(resolvedHref ?? onClick);
return (
<li
@@ -69,13 +71,13 @@ const BreadcrumbsItem = memo(function BreadcrumbsItem({
data-testid={testId}
>
<Text
as={href ? 'a' : onClick ? 'button' : 'span'}
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={href}
href={resolvedHref}
size="m"
type="label"
onClick={onClick}
+3 -1
View File
@@ -1,3 +1,4 @@
import { localePath } from '@ermnvldmr/i18n';
import { castRef, cn } from '@ermnvldmr/stl';
import React, { memo, useRef } from 'react';
import { useLink } from 'react-aria';
@@ -50,6 +51,7 @@ export const Link = memo(function Link(props: LinkProps) {
);
if (href) {
const resolvedHref = localePath(href);
const isAutoExternal = isExternal ?? href.startsWith('http');
const externalProps = isAutoExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {};
@@ -62,7 +64,7 @@ export const Link = memo(function Link(props: LinkProps) {
className={sharedClasses}
color={color}
delay={delay}
href={href}
href={resolvedHref}
size={size}
type={type}
>
@@ -51,9 +51,6 @@ const MarkdownComponent = memo(function Markdown({ children, className }: Markdo
* <Markdown.H1>Title</Markdown.H1>
* <Markdown.P>Content goes here.</Markdown.P>
* </Markdown>
*
* // Or with Astro:
* <Content components={MARKDOWN_COMPONENTS} />
* ```
*/
export const Markdown = Object.assign(MarkdownComponent, {
@@ -24,7 +24,7 @@ import { MarkdownUL } from './MarkdownUL/MarkdownUL';
import type React from 'react';
/**
* A map of HTML tags to Markdown components for use with MDX or Astro Content.
* A map of HTML tags to Markdown components for use with MDX or other Markdown systems.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const MARKDOWN_COMPONENTS: Record<string, React.ComponentType<any>> = {
@@ -33,6 +33,6 @@ describe('PageHead', () => {
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
const header = screen.getByRole('banner');
expect(header).toHaveClass('fixed');
expect(header).toHaveClass('shadow-md');
expect(header).not.toHaveClass('sticky');
});
});
@@ -1,5 +1,5 @@
import { cn, useScroll, useResizeObserver } from '@ermnvldmr/stl';
import React, { type ReactNode, useRef, useEffect, useState } from 'react';
import { cn, useScroll } from '@ermnvldmr/stl';
import React, { type ReactNode, useEffect, useState } from 'react';
import { Header } from '../../Header/Header';
import { HStack } from '../../HStack/HStack';
@@ -67,21 +67,29 @@ export const PageHead = ({
className,
}: PageHeadProps) => {
const { y: scrollY } = useScroll();
const [isCollapsed, setIsCollapsed] = useState(strategy.includes('always-collapsed'));
const headerRef = useRef<HTMLDivElement>(null);
const { height } = useResizeObserver(headerRef);
const isCollapsible = strategy === 'collapsible-sticky';
const isAlwaysCollapsed = strategy.startsWith('always-collapsed');
const [isCollapsed, setIsCollapsed] = useState(isAlwaysCollapsed);
useEffect(() => {
if (strategy === 'collapsible-sticky') {
setIsCollapsed(scrollY > 10);
if (isCollapsible) {
// Trigger collapse once we've scrolled past the expanded header's height.
// Usually around 150-200px depending on caption length.
// 200px ensures it won't snap until it's mostly gone.
setIsCollapsed(scrollY > 200);
}
}, [scrollY, strategy]);
}, [scrollY, isCollapsible]);
const rootClasses = cn(
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] fixed top-0 z-10 border-b border-[var(--rb-outline)]/10',
{
'shadow-md bg-[var(--rb-background)]/90 backdrop-blur-md': isCollapsed,
},
'page-head w-full bg-[var(--rb-background)] border-b border-[var(--rb-outline)]/10',
// Always-collapsed or always-expanded strategies maintain their sticky/fixed behavior.
// Collapsible-sticky strategy keeps the root in-flow so expanded content scrolls away.
!isCollapsible &&
(strategy.includes('sticky')
? 'sticky top-0 z-10'
: strategy.includes('fixed')
? 'fixed top-0 z-10'
: ''),
className
);
@@ -95,7 +103,7 @@ export const PageHead = ({
</HStack>
{addonRight && <div>{addonRight}</div>}
</HStack>
<VStack gap={2}>
<VStack gap={4}>
<Header level={1}>{heading}</Header>
{typeof caption === 'string' ? (
<Text color="muted" size="l" type="body">
@@ -124,26 +132,31 @@ export const PageHead = ({
);
return (
<>
<header ref={headerRef} className={rootClasses}>
<header className={rootClasses}>
{/* Expanded Content: In-flow for collapsible, or sticky/fixed for others */}
{!isAlwaysCollapsed && (
<div
className={cn('transition-all duration-300', {
'opacity-0 invisible h-0 overflow-hidden': isCollapsed,
'opacity-100 visible': !isCollapsed,
})}
className={cn(
'transition-opacity duration-300',
isCollapsible && isCollapsed ? 'opacity-0 pointer-events-none' : 'opacity-100'
)}
>
{expandedContent}
</div>
<div
className={cn('transition-all duration-300', {
'opacity-100 visible': isCollapsed,
'opacity-0 invisible h-0 overflow-hidden': !isCollapsed,
})}
>
{collapsedContent}
</div>
</header>
<div style={{ height }} />
</>
)}
{/* Collapsed Content: Fixed overlay for collapsible, or absolute/relative within sticky/fixed root */}
<div
className={cn(
'z-20 transition-all duration-300 transform bg-[var(--rb-background)]/90 backdrop-blur-md border-b border-[var(--rb-outline)]/10 shadow-md',
isAlwaysCollapsed || (isCollapsible && isCollapsed)
? 'translate-y-0 opacity-100'
: '-translate-y-full opacity-0 pointer-events-none',
isCollapsible ? 'fixed top-0 left-0 right-0' : 'absolute top-0 left-0 right-0'
)}
>
{collapsedContent}
</div>
</header>
);
};
@@ -129,4 +129,52 @@ describe('components/Text', () => {
);
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
});
// TDD: fails before implementation (old code uses text-[14px], not text-rb-body-m)
it('applies text-rb-{type}-{size} class', () => {
render(
<Text size="m" type="body">
Content
</Text>
);
expect(screen.getByText('Content')).toHaveClass('text-rb-body-m');
});
it('does not apply legacy px-based font-size classes', () => {
render(
<Text size="m" type="body">
Content
</Text>
);
expect(screen.getByText('Content')).not.toHaveClass('text-[14px]');
expect(screen.getByText('Content')).not.toHaveClass('leading-[20px]');
});
// Regression guards: pass before and after
it('applies font-medium for label type', () => {
render(
<Text size="m" type="label">
Label
</Text>
);
expect(screen.getByText('Label')).toHaveClass('font-medium');
});
it('applies font-sans for body type', () => {
render(
<Text size="m" type="body">
Body
</Text>
);
expect(screen.getByText('Body')).toHaveClass('font-sans');
});
it('applies font-serif for display type', () => {
render(
<Text size="l" type="display">
Display
</Text>
);
expect(screen.getByText('Display')).toHaveClass('font-serif');
});
});
+29 -96
View File
@@ -1,29 +1,19 @@
import { cn, genericMemo, useIntersectionObserver, castMutableRef } from '@ermnvldmr/stl';
import React, { forwardRef, useState, useEffect, useRef } from 'react';
import {
getTypographyClassNames,
type TextType,
type TextSize,
type TextColor,
type TextAlign,
type TextWrap,
type TextOverflow,
} from '../../lib/typography';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Typography types.
*/
export type TextType = 'display' | 'headline' | 'title' | 'body' | 'label';
/**
* Typography sizes.
*/
export type TextSize = 's' | 'm' | 'l';
/**
* Text color variants.
*/
export type TextColor =
| 'default'
| 'primary'
| 'secondary'
| 'tertiary'
| 'error'
| 'muted'
| 'inherit';
export type { TextType, TextSize, TextColor, TextAlign, TextWrap, TextOverflow };
/**
* Props for the Text component.
@@ -53,11 +43,11 @@ export interface TextProps
/** Optional href for links */
href?: string;
/** Text alignment */
align?: 'left' | 'center' | 'right' | 'justify';
align?: TextAlign;
/** Text wrapping behavior */
wrap?: 'nowrap' | 'balance' | 'pretty';
wrap?: TextWrap;
/** Overflow behavior for single lines */
overflow?: 'ellipsis' | 'clip';
overflow?: TextOverflow;
/** Maximum number of lines to show (truncates with ellipsis) */
maxLines?: number;
/** Delay in milliseconds before the text appears after entering the viewport. */
@@ -69,8 +59,8 @@ export interface TextProps
/**
* A flexible typography component that implements the design system's type scale.
*
* Text centralizes all typography rules, including font families, sizes,
* line heights, and letter spacing, reducing the need for custom CSS.
* Text centralizes all typography rules — font families, sizes, line heights,
* letter spacing, colors, and decorations — via the getTypographyClassNames utility.
*/
const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
{
@@ -98,7 +88,6 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
const [isVisible, setIsVisible] = useState(delay === undefined);
const internalRef = useRef<HTMLElement>(null);
// Sync internalRef with forwarded ref
useEffect(() => {
if (!ref) return;
if (typeof ref === 'function') {
@@ -125,80 +114,24 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
return undefined;
}, [delay, isIntersecting]);
// Base typography styles mapping
const typeStyles: Record<TextType, Record<TextSize, string>> = {
display: {
l: 'font-serif text-[57px] leading-[64px] tracking-[-0.25px]',
m: 'font-serif text-[45px] leading-[52px] tracking-normal',
s: 'font-serif text-[36px] leading-[44px] tracking-normal',
},
headline: {
l: 'font-serif text-[32px] leading-[40px] tracking-normal',
m: 'font-serif text-[28px] leading-[36px] tracking-normal',
s: 'font-serif text-[24px] leading-[32px] tracking-normal',
},
title: {
l: 'font-sans text-[22px] leading-[28px] tracking-normal',
m: 'font-sans text-[16px] leading-[24px] tracking-[0.15px]',
s: 'font-sans text-[14px] leading-[20px] tracking-[0.1px]',
},
body: {
l: 'font-sans text-[16px] leading-[24px] tracking-[0.5px]',
m: 'font-sans text-[14px] leading-[20px] tracking-[0.25px]',
s: 'font-sans text-[12px] leading-[16px] tracking-[0.4px]',
},
label: {
l: 'font-sans font-medium text-[14px] leading-[20px] tracking-[0.1px]',
m: 'font-sans font-medium text-[12px] leading-[16px] tracking-[0.5px]',
s: 'font-sans font-medium text-[11px] leading-[16px] tracking-[0.5px]',
},
};
const colorClasses: Record<TextColor, string> = {
default: 'text-[var(--rb-text)]',
primary: 'text-[var(--rb-primary-text)]',
secondary: 'text-[var(--rb-secondary-text)]',
tertiary: 'text-[var(--rb-tertiary-text)]',
error: 'text-[var(--rb-error-text)]',
muted: 'text-[var(--rb-muted-text)]',
inherit: 'text-inherit',
};
const alignClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
};
const wrapClasses = {
nowrap: 'whitespace-nowrap',
balance: 'text-balance',
pretty: 'text-pretty',
};
let truncationClass = '';
if (maxLines) {
truncationClass = `line-clamp-${maxLines}`;
} else {
if (overflow === 'ellipsis') truncationClass = 'truncate';
if (overflow === 'clip') truncationClass = 'overflow-clip';
}
return (
<Component
{...props}
ref={internalRef}
className={cn(
typeStyles[type][size],
colorClasses[color],
bold && 'font-bold',
italic && 'italic',
underline && 'underline',
strike && 'line-through',
align && alignClasses[align],
wrap && wrapClasses[wrap],
truncationClass,
getTypographyClassNames({
type,
size,
color,
bold,
italic,
underline,
strike,
align,
wrap,
overflow,
maxLines,
}),
delay !== undefined &&
(isVisible
? 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out'
+2
View File
@@ -1,4 +1,6 @@
export * from './Accordion/index';
export * from './Article/Article';
export * from './BentoGrid/index';
export * from './Blockquote/Blockquote';
export * from './Breadcrumbs/Breadcrumbs';
export * from './Button/Button';
+1
View File
@@ -1 +1,2 @@
export * from './theme';
export * from './typography';
@@ -0,0 +1,140 @@
import { getTypographyClassNames } from './getTypographyClassNames';
describe('lib/typography/getTypographyClassNames', () => {
describe('type + size → @theme token class', () => {
it('returns text-rb-body-m by default', () => {
expect(getTypographyClassNames()).toContain('text-rb-body-m');
});
it('returns correct class for each type/size combination', () => {
expect(getTypographyClassNames({ type: 'display', size: 'l' })).toContain(
'text-rb-display-l'
);
expect(getTypographyClassNames({ type: 'headline', size: 'm' })).toContain(
'text-rb-headline-m'
);
expect(getTypographyClassNames({ type: 'title', size: 's' })).toContain('text-rb-title-s');
expect(getTypographyClassNames({ type: 'body', size: 'l' })).toContain('text-rb-body-l');
expect(getTypographyClassNames({ type: 'label', size: 'm' })).toContain('text-rb-label-m');
});
});
describe('font family', () => {
it('applies font-serif for display and headline', () => {
expect(getTypographyClassNames({ type: 'display' })).toContain('font-serif');
expect(getTypographyClassNames({ type: 'headline' })).toContain('font-serif');
});
it('applies font-sans for title, body, and label', () => {
expect(getTypographyClassNames({ type: 'title' })).toContain('font-sans');
expect(getTypographyClassNames({ type: 'body' })).toContain('font-sans');
expect(getTypographyClassNames({ type: 'label' })).toContain('font-sans');
});
});
describe('font weight', () => {
it('adds font-medium for label type', () => {
expect(getTypographyClassNames({ type: 'label' })).toContain('font-medium');
});
it('does not add font-medium for non-label types', () => {
expect(getTypographyClassNames({ type: 'body' })).not.toContain('font-medium');
expect(getTypographyClassNames({ type: 'title' })).not.toContain('font-medium');
});
it('adds font-bold when bold is true', () => {
expect(getTypographyClassNames({ bold: true })).toContain('font-bold');
});
it('does not add font-bold by default', () => {
expect(getTypographyClassNames()).not.toContain('font-bold');
});
});
describe('color', () => {
it('applies default color class', () => {
expect(getTypographyClassNames()).toContain('text-[var(--rb-text)]');
});
it('applies each color variant', () => {
expect(getTypographyClassNames({ color: 'muted' })).toContain('text-[var(--rb-muted-text)]');
expect(getTypographyClassNames({ color: 'primary' })).toContain(
'text-[var(--rb-primary-text)]'
);
expect(getTypographyClassNames({ color: 'error' })).toContain('text-[var(--rb-error-text)]');
expect(getTypographyClassNames({ color: 'inherit' })).toContain('text-inherit');
});
});
describe('decorations', () => {
it('applies italic', () => {
expect(getTypographyClassNames({ italic: true })).toContain('italic');
});
it('applies underline', () => {
expect(getTypographyClassNames({ underline: true })).toContain('underline');
});
it('applies line-through for strike', () => {
expect(getTypographyClassNames({ strike: true })).toContain('line-through');
});
it('does not apply decorations by default', () => {
const result = getTypographyClassNames();
expect(result).not.toContain('italic');
expect(result).not.toContain('underline');
expect(result).not.toContain('line-through');
});
});
describe('alignment', () => {
it('applies text-center', () => {
expect(getTypographyClassNames({ align: 'center' })).toContain('text-center');
});
it('applies each alignment', () => {
expect(getTypographyClassNames({ align: 'left' })).toContain('text-left');
expect(getTypographyClassNames({ align: 'right' })).toContain('text-right');
expect(getTypographyClassNames({ align: 'justify' })).toContain('text-justify');
});
it('does not apply alignment by default', () => {
const result = getTypographyClassNames();
expect(result).not.toMatch(/text-(left|center|right|justify)/);
});
});
describe('wrap', () => {
it('applies whitespace-nowrap', () => {
expect(getTypographyClassNames({ wrap: 'nowrap' })).toContain('whitespace-nowrap');
});
it('applies text-balance', () => {
expect(getTypographyClassNames({ wrap: 'balance' })).toContain('text-balance');
});
it('applies text-pretty', () => {
expect(getTypographyClassNames({ wrap: 'pretty' })).toContain('text-pretty');
});
});
describe('overflow / maxLines', () => {
it('applies truncate for overflow ellipsis', () => {
expect(getTypographyClassNames({ overflow: 'ellipsis' })).toContain('truncate');
});
it('applies overflow-clip for overflow clip', () => {
expect(getTypographyClassNames({ overflow: 'clip' })).toContain('overflow-clip');
});
it('applies line-clamp for maxLines', () => {
expect(getTypographyClassNames({ maxLines: 3 })).toContain('line-clamp-3');
});
it('maxLines takes precedence over overflow', () => {
const result = getTypographyClassNames({ maxLines: 2, overflow: 'ellipsis' });
expect(result).toContain('line-clamp-2');
expect(result).not.toContain('truncate');
});
});
});
@@ -0,0 +1,158 @@
/**
* Typography type roles.
* Determines font family and base visual treatment.
*/
export type TextType = 'display' | 'headline' | 'title' | 'body' | 'label';
/**
* Typography size scale.
* Applied within a type role to select the appropriate @theme token.
*/
export type TextSize = 's' | 'm' | 'l';
/**
* Text color variants mapped to design-system CSS custom properties.
*/
export type TextColor =
| 'default'
| 'primary'
| 'secondary'
| 'tertiary'
| 'error'
| 'muted'
| 'inherit';
/**
* Text alignment.
*/
export type TextAlign = 'left' | 'center' | 'right' | 'justify';
/**
* Text wrapping behaviour.
*/
export type TextWrap = 'nowrap' | 'balance' | 'pretty';
/**
* Overflow handling for single-line text.
*/
export type TextOverflow = 'ellipsis' | 'clip';
/**
* Full set of typography options supported by getTypographyClassNames.
*/
export interface TypographyOptions {
/** Typography role — determines font family and base style. */
type?: TextType;
/** Size scale within the role. */
size?: TextSize;
/** Text color variant. */
color?: TextColor;
/** Bold weight. */
bold?: boolean;
/** Italic style. */
italic?: boolean;
/** Underline decoration. */
underline?: boolean;
/** Strikethrough decoration. */
strike?: boolean;
/** Text alignment. */
align?: TextAlign;
/** Text wrapping behaviour. */
wrap?: TextWrap;
/** Overflow truncation for single lines. */
overflow?: TextOverflow;
/** Maximum visible lines (truncates with ellipsis). Takes precedence over overflow. */
maxLines?: number;
}
const fontFamilyClasses: Record<TextType, string> = {
display: 'font-serif',
headline: 'font-serif',
title: 'font-sans',
body: 'font-sans',
label: 'font-sans',
};
const colorClasses: Record<TextColor, string> = {
default: 'text-[var(--rb-text)]',
primary: 'text-[var(--rb-primary-text)]',
secondary: 'text-[var(--rb-secondary-text)]',
tertiary: 'text-[var(--rb-tertiary-text)]',
error: 'text-[var(--rb-error-text)]',
muted: 'text-[var(--rb-muted-text)]',
inherit: 'text-inherit',
};
const alignClasses: Record<TextAlign, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
};
const wrapClasses: Record<TextWrap, string> = {
nowrap: 'whitespace-nowrap',
balance: 'text-balance',
pretty: 'text-pretty',
};
/**
* Returns a space-separated string of Tailwind CSS classes for the given
* typography options.
*
* The returned string is safe to pass directly to `cn()` alongside other
* classes — it contains no internal conflicts. Tailwind-merge is configured
* (via the stl `cn` utility) to treat `text-rb-{type}-{size}` as a
* font-size group so it never conflicts with text-color classes.
*
* @example
* ```tsx
* // In a component:
* <p className={cn(getTypographyClassNames({ type: 'body', size: 'l' }), className)}>
*
* // In a CSS-cascade parent/child pattern:
* <div className="rb-scale-l">
* <p className={cn(getTypographyClassNames({ type: 'body' }))}>…</p>
* </div>
* ```
*/
export function getTypographyClassNames(options: TypographyOptions = {}): string {
const {
type = 'body',
size = 'm',
color = 'default',
bold = false,
italic = false,
underline = false,
strike = false,
align,
wrap,
overflow,
maxLines,
} = options;
let truncationClass = '';
if (maxLines) {
truncationClass = `line-clamp-${maxLines}`;
} else if (overflow === 'ellipsis') {
truncationClass = 'truncate';
} else if (overflow === 'clip') {
truncationClass = 'overflow-clip';
}
return [
`text-rb-${type}-${size}`,
fontFamilyClasses[type],
type === 'label' && 'font-medium',
colorClasses[color],
bold && 'font-bold',
italic && 'italic',
underline && 'underline',
strike && 'line-through',
align && alignClasses[align],
wrap && wrapClasses[wrap],
truncationClass,
]
.filter(Boolean)
.join(' ');
}
+1
View File
@@ -0,0 +1 @@
export * from './getTypographyClassNames/getTypographyClassNames';
+134
View File
@@ -76,6 +76,11 @@
@import 'tailwindcss';
@import 'tw-animate-css';
/* Safelist: text-rb-{type}-{size} utilities are assembled via template literals
in getTypographyClassNames and cannot be detected by Tailwind's static scanner.
@source inline forces inclusion regardless of source scanning. */
@source inline("text-rb-display-l text-rb-display-m text-rb-display-s text-rb-headline-l text-rb-headline-m text-rb-headline-s text-rb-title-l text-rb-title-m text-rb-title-s text-rb-body-l text-rb-body-m text-rb-body-s text-rb-label-l text-rb-label-m text-rb-label-s");
@custom-variant dark (&:where(.dark, .dark *));
@theme {
@@ -184,6 +189,66 @@
--rb-color-neutral-975: #141414;
}
@theme {
/* display */
--text-rb-display-l: 3.5625rem;
--text-rb-display-l--line-height: 4rem;
--text-rb-display-l--letter-spacing: -0.015625rem;
--text-rb-display-m: 2.8125rem;
--text-rb-display-m--line-height: 3.25rem;
--text-rb-display-m--letter-spacing: 0rem;
--text-rb-display-s: 2.25rem;
--text-rb-display-s--line-height: 2.75rem;
--text-rb-display-s--letter-spacing: 0rem;
/* headline */
--text-rb-headline-l: 2rem;
--text-rb-headline-l--line-height: 2.5rem;
--text-rb-headline-l--letter-spacing: 0rem;
--text-rb-headline-m: 1.75rem;
--text-rb-headline-m--line-height: 2.25rem;
--text-rb-headline-m--letter-spacing: 0rem;
--text-rb-headline-s: 1.5rem;
--text-rb-headline-s--line-height: 2rem;
--text-rb-headline-s--letter-spacing: 0rem;
/* title */
--text-rb-title-l: 1.375rem;
--text-rb-title-l--line-height: 1.75rem;
--text-rb-title-l--letter-spacing: 0rem;
--text-rb-title-m: 1rem;
--text-rb-title-m--line-height: 1.5rem;
--text-rb-title-m--letter-spacing: 0.009375rem;
--text-rb-title-s: 0.875rem;
--text-rb-title-s--line-height: 1.25rem;
--text-rb-title-s--letter-spacing: 0.00625rem;
/* body — baseline raised: m = 1rem (was 14px/0.875rem) */
--text-rb-body-l: 1.125rem;
--text-rb-body-l--line-height: 1.75rem;
--text-rb-body-l--letter-spacing: 0.03125rem;
--text-rb-body-m: 1rem;
--text-rb-body-m--line-height: 1.5rem;
--text-rb-body-m--letter-spacing: 0.015625rem;
--text-rb-body-s: 0.875rem;
--text-rb-body-s--line-height: 1.25rem;
--text-rb-body-s--letter-spacing: 0.025rem;
/* label */
--text-rb-label-l: 0.875rem;
--text-rb-label-l--line-height: 1.25rem;
--text-rb-label-l--letter-spacing: 0.00625rem;
--text-rb-label-l--font-weight: 500;
--text-rb-label-m: 0.75rem;
--text-rb-label-m--line-height: 1rem;
--text-rb-label-m--letter-spacing: 0.03125rem;
--text-rb-label-m--font-weight: 500;
--text-rb-label-s: 0.6875rem;
--text-rb-label-s--line-height: 1rem;
--text-rb-label-s--letter-spacing: 0.03125rem;
--text-rb-label-s--font-weight: 500;
}
:root {
/* Core palette */
--rb-base: var(--rb-color-neutral-50);
@@ -250,6 +315,75 @@
--rb-chart-5: var(--rb-color-red-400);
}
@layer components {
/* ── Scale parents — set CSS vars for all type children below ── */
.rb-scale-s {
--rb-sz-display: var(--text-rb-display-s);
--rb-lh-display: var(--text-rb-display-s--line-height);
--rb-sz-headline: var(--text-rb-headline-s);
--rb-lh-headline: var(--text-rb-headline-s--line-height);
--rb-sz-title: var(--text-rb-title-s);
--rb-lh-title: var(--text-rb-title-s--line-height);
--rb-sz-body: var(--text-rb-body-s);
--rb-lh-body: var(--text-rb-body-s--line-height);
--rb-sz-label: var(--text-rb-label-s);
--rb-lh-label: var(--text-rb-label-s--line-height);
}
.rb-scale-m {
--rb-sz-display: var(--text-rb-display-m);
--rb-lh-display: var(--text-rb-display-m--line-height);
--rb-sz-headline: var(--text-rb-headline-m);
--rb-lh-headline: var(--text-rb-headline-m--line-height);
--rb-sz-title: var(--text-rb-title-m);
--rb-lh-title: var(--text-rb-title-m--line-height);
--rb-sz-body: var(--text-rb-body-m);
--rb-lh-body: var(--text-rb-body-m--line-height);
--rb-sz-label: var(--text-rb-label-m);
--rb-lh-label: var(--text-rb-label-m--line-height);
}
.rb-scale-l {
--rb-sz-display: var(--text-rb-display-l);
--rb-lh-display: var(--text-rb-display-l--line-height);
--rb-sz-headline: var(--text-rb-headline-l);
--rb-lh-headline: var(--text-rb-headline-l--line-height);
--rb-sz-title: var(--text-rb-title-l);
--rb-lh-title: var(--text-rb-title-l--line-height);
--rb-sz-body: var(--text-rb-body-l);
--rb-lh-body: var(--text-rb-body-l--line-height);
--rb-sz-label: var(--text-rb-label-l);
--rb-lh-label: var(--text-rb-label-l--line-height);
}
/* ── Type children — declare type, read from parent scale vars ── */
/* Fallback (second arg to var()) applies m-size when no rb-scale-* parent exists */
.rb-text-display {
font-family: var(--font-serif);
font-size: var(--rb-sz-display, var(--text-rb-display-m));
line-height: var(--rb-lh-display, var(--text-rb-display-m--line-height));
}
.rb-text-headline {
font-family: var(--font-serif);
font-size: var(--rb-sz-headline, var(--text-rb-headline-m));
line-height: var(--rb-lh-headline, var(--text-rb-headline-m--line-height));
}
.rb-text-title {
font-family: var(--font-sans);
font-size: var(--rb-sz-title, var(--text-rb-title-m));
line-height: var(--rb-lh-title, var(--text-rb-title-m--line-height));
}
.rb-text-body {
font-family: var(--font-sans);
font-size: var(--rb-sz-body, var(--text-rb-body-m));
line-height: var(--rb-lh-body, var(--text-rb-body-m--line-height));
}
.rb-text-label {
font-family: var(--font-sans);
font-weight: 500;
font-size: var(--rb-sz-label, var(--text-rb-label-m));
line-height: var(--rb-lh-label, var(--text-rb-label-m--line-height));
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
+2564 -513
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -7,6 +7,6 @@ title: "Ermnvldmr's Documentation"
Welcome to the unified documentation for my own projects
{{< cards >}}
{{< card link="root" title="root.ermnvldmr.com" icon="server" subtitle="Documentation for root.ermnvldmr.com infrastructure" >}}
{{< card link="root" title="ermnvldmr.com-root-commons" icon="server" subtitle="Documentation for the root deployment commons framework" >}}
{{< card link="quetza" title="Quetza Bot" icon="terminal" subtitle="Modular Discord bot framework documentation" >}}
{{< /cards >}}
@@ -1,6 +1,6 @@
---
title: Conversational AI Integration
weight: 2
weight: 4
---
**Goal:** Integrate Large Language Models (LLMs) while managing context and Discord's timeout constraints.
@@ -51,5 +51,6 @@ export async function execute(interaction, controller) {
}
```
> [!WARNING] > **API Costs:**
> [!WARNING]
> **API Costs:**
> AI APIs are often billed by token. Implement strict user-based rate limits in your Controller to prevent abuse.
@@ -65,7 +65,8 @@ Once running, use these mechanisms to check health:
- **`/ping` Command:** Verifies network latency and API responsiveness.
- **`/modules` Command:** Lists all loaded modules to confirm feature availability.
> [!WARNING] > **Scaling Constraint:**
> [!WARNING]
> **Scaling Constraint:**
> Quetza follows a single-instance-per-token model. Do not run multiple instances with the same token, as this will cause WebSocket conflicts.
---
@@ -1,6 +1,6 @@
---
title: Interactive Voice Features
weight: 1
weight: 3
---
**Goal:** Implement sophisticated voice interactions that respect guild boundaries and manage ephemeral resources.
@@ -32,7 +32,8 @@ Audio sessions are ephemeral.
- **Transient State:** Active audio stream (Lost on disconnect).
- **Durable State:** Queue/Playlist (Persisted in Controller until restart).
> [!TIP] > **Resilience:** Separate the _Queue_ logic from the _Voice Connection_ logic. This allows the bot to reconnect without losing the user's playlist.
> [!TIP]
> **Resilience:** Separate the _Queue_ logic from the _Voice Connection_ logic. This allows the bot to reconnect without losing the user's playlist.
## 4. Implementation Sketch
@@ -1,13 +0,0 @@
---
title: Recipes
weight: 3
---
**Quetza** recipes are strategic architectural patterns for common functional requirements. They focus on _how_ to compose framework features to solve business problems.
## Recipe Categories
{{% cards %}}
{{< card link="voice-features" title="Interactive Voice Features" icon="users" subtitle="Managing audio players and guild-scoped state." >}}
{{< card link="conversational-ai" title="Conversational AI" icon="chip" subtitle="Handling LLM context and long-running responses." >}}
{{% /cards %}}
@@ -22,18 +22,15 @@ weight: 1
**Symptom:** `/command` does not appear in Discord.
### Global vs. Guild Registration
### Global vs. Test Guild Registration
- **Global:** Takes up to 1 hour to propagate.
- **Guild:** Instant update.
- **Fix:** Set `DEV_GUILD_ID` in your `.env` for development.
- **Global:** Commands pushed to the application globally can take up to 1 hour to propagate and are subject to stricter rate limits.
- **Test Guild:** Quetza automatically pushes commands to a specific guild in development mode for instant updates.
- **Requirement:** Ensure `NODE_ENV=development` and `DEV_GUILD_ID` is correctly set in your `.env`.
### Data Malformation
### Rate Limits
Discord rejects invalid command structures silently or with generic errors.
- **Name:** Must be lowercase, alphanumeric, `-` only.
- **Description:** Required, max 100 chars.
Frequent updates to global commands may trigger Discord's rate limits. Use a test guild for iterative development.
## 3. Interaction Failures
@@ -71,5 +68,6 @@ Check your Controller.
- **Multiple:** Events are additive. Did you register the same handler in two modules?
- **None:** Did you export `name` and `execute` correctly?
> [!TIP] > **Debug Mode:**
> [!TIP]
> **Debug Mode:**
> Enable verbose logging to see exactly which modules and commands are loaded at startup.
+16 -5
View File
@@ -1,22 +1,33 @@
---
title: 'Root Infrastructure'
description: 'Documentation for the root.ermnvldmr.com infrastructure.'
title: Documentation of ermnvldmr.com root deployment commons
description: Documentation for the ermnvldmr.com root deployment commons framework.
weight: 1
type: docs
cascade:
type: docs
---
Comprehensive documentation for the personal infrastructure deployment and management, including setup guides and operational recipes.
The `ermnvldmr.com-root-commons` framework provides a minimal-dependency deployment environment for independent, self-hosted projects.
The generic logic, scripts, and deployment patterns used across multiple unrelated self-hosted applications are stored in the [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons) repository.
## Core features
- **Smart Dispatcher**: A single entrypoint (`root`) for all orchestration tasks.
- **Tiered Storage**: Standardized classification for configuration, data, and logs.
- **Privilege Isolation**: Project-specific non-root users for enhanced security.
- **Namespaced Libraries**: Explicit, modular Bash utilities for consistent automation.
## Sections
{{% cards %}}
{{< card link="recipes" title="Recipes" icon="beaker" subtitle="Collection of step-by-step procedures for infrastructure maintenance and deployment." >}}
{{< card link="concepts" title="Concepts" icon="book-open" subtitle="Theoretical foundations, architecture, and security models." >}}
{{< card link="guides" title="Guides" icon="map" subtitle="Step-by-step guides for installation and action development." >}}
{{< card link="reference" title="Reference" icon="code" subtitle="API and technical references for developers." >}}
{{% /cards %}}
---
**See also:**
- [GitHub: root.ermnvldmr.com](https://github.com/deytenit/root.ermnvldmr.com)
- [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons)
@@ -0,0 +1,14 @@
---
title: Concepts
description: Theoretical foundations, architecture, and security models.
weight: 10
---
Theoretical foundations, architecture, and security models for the ermnvldmr.com root deployment commons framework.
{{% cards %}}
{{< card link="principles" title="Infrastructure principles" icon="book-open" subtitle="Predictability, minimal dependencies, and decentralization." >}}
{{< card link="architecture" title="Infrastructure architecture" icon="share" subtitle="Decentralized orchestration and deployment models." >}}
{{< card link="security-model" title="Security & privilege isolation" icon="shield-check" subtitle="Privilege isolation and network hardening." >}}
{{< card link="tiered-storage" title="Tiered storage model" icon="database" subtitle="Standardized storage classification for service data." >}}
{{% /cards %}}
@@ -0,0 +1,70 @@
---
title: Infrastructure architecture
description: The decentralized orchestration model and deployment structure.
weight: 10
---
The Root framework operates on a decentralized orchestration model designed for independent, self-contained projects. It enforces a strict separation between the orchestrator (the "brain") and the automation tasks (the "muscle").
## Core philosophy {#philosophy}
The framework is built on the **Smart Root, Stupid Actions** principle. This design ensures that individual automation scripts remain declarative and unaware of the global system state, while the central dispatcher handles environment resolution and library initialization.
### Smart Root (The Dispatcher)
The `root` entrypoint is the sole interface for users. It is responsible for:
- **Environment resolution** &rarr; Locating the `commons` and `configs` repositories.
- **Library warming** &rarr; Sourcing namespaced modules exactly once.
- **Context establishment** &rarr; Defining the target [Node](/root/glossary#node) and its associated paths.
### Stupid Actions (The Tasks)
[Actions](/root/glossary#action) are declarative scripts located in `scripts/actions/`. They are "stupid" because:
- They do not calculate paths or detect repositories.
- They depend entirely on the `ROOT_` environment variables.
- They focus exclusively on a single atomic task (e.g., `compose`, `ufw`).
## Deployment model {#deployment}
The framework is typically deployed as a shared [Commons](/root/glossary#commons) repository mounted as a Git submodule within a configuration repository. The engine powering this shared infrastructure is hosted at [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons).
{{< filetree/container >}}
{{< filetree/folder name="configs-repo" state="open" >}}
{{< filetree/folder name=".operator" state="open" >}}
{{< filetree/folder name="shared (submodule)" state="open" >}}
{{< filetree/folder name="scripts" >}}
{{< filetree/folder name="run" >}}
{{< filetree/file name="root (dispatcher)" >}}
{{< /filetree/folder >}}
{{< filetree/folder name="lib" >}}
{{< filetree/file name="shared libraries" >}}
{{< /filetree/folder >}}
{{< filetree/folder name="actions" >}}
{{< filetree/file name="shared actions" >}}
{{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< filetree/folder name="local" state="open" >}}
{{< filetree/folder name="scripts" >}}
{{< filetree/folder name="actions" >}}
{{< filetree/file name="node-specific overrides" >}}
{{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< filetree/folder name="daedalus (node)" >}}
{{< filetree/file name="docker-compose.yml" >}}
{{< /filetree/folder >}}
{{< filetree/file name="init.sh (bootstrap)" >}}
{{< /filetree/folder >}}
{{< /filetree/container >}}
---
**See also:**
- [Concept: The ROOT_ API](/root/reference/root-api)
- [Guide: Installation](/root/guides/installation)
- [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons)
@@ -0,0 +1,33 @@
---
title: Infrastructure principles
description: Core principles providing a secure, predictable, and minimal-dependency environment.
weight: 5
---
The Root framework is built on core principles designed to provide a secure, predictable, and minimal-dependency environment for independent self-hosted projects.
## Predictability through structure {#predictability}
Manual server configuration is prone to "configuration drift," where hosts intended to be identical slowly diverge. Root solves this by:
- **Git-centric truth**: Every configuration, from UFW rules to crontabs, is stored in the repository.
- **Idempotent automation**: [Actions](/root/glossary#action) are designed to be run multiple times without causing side effects, ensuring the host always matches the intended state.
## Minimal dependency footprint {#minimalism}
To ensure long-term maintainability, the framework avoids complex configuration management tools (like Ansible or Salt). Instead, it relies on:
- **Bash**: The universal language of Unix systems.
- **Docker**: For application isolation and environment consistency.
- **Standard Linux tools**: Leveraging `systemd`, `ufw`, and `cron` directly.
## Decentralization {#decentralization}
Each [Node](/root/glossary#node) is an island. While they share the [Commons](/root/glossary#commons) library, they do not depend on a central "master" server. This architecture ensures that a failure in one host does not impact unrelated services.
---
**See also:**
- [Concept: Security model](/root/concepts/security-model)
- [Concept: Tiered storage](/root/concepts/tiered-storage)
@@ -0,0 +1,71 @@
---
title: Security & privilege isolation
description: Security measures, privilege isolation, and project-level sandboxing.
weight: 20
---
Security in Root is achieved through strict privilege isolation and project-level "sandboxing" to mitigate the impact of service compromises.
## The "No-Root" Principle {#noroot}
Running applications as `root` inside containers is a significant security risk. The framework enforces isolation via the `tiers/useradd` and `tiers/chown` actions.
### Project-specific users
Every project located at `{repository root}/{node}/{project}` is assigned a dedicated system user on the host.
- **Isolation**: If a container is compromised, the attacker only gains the privileges of that specific low-privileged user.
- **Filesystem safety**: Projects can only read/write to their own directories within the [Tiers](/root/glossary#tier).
## Docker socket protection {#socket-protection}
Direct access to `/var/run/docker.sock` is equivalent to root access on the host. As a best practice, it is highly recommended to protect the Docker socket. One way to achieve this is by utilizing a **Socket Proxy** (e.g., based on `tecnativa/docker-socket-proxy`) to filter and restrict access to the Docker API.
```yaml filename="docker-socket-proxy-example.yml"
services:
socket-proxy:
image: tecnativa/docker-socket-proxy:0
container_name: root-socket-proxy
user: "0:0"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
CONTAINERS: 1
SERVICES: 1
TASKS: 1
networks:
socket: {}
restart: unless-stopped
traefik:
# ...
depends_on:
- socket-proxy
command:
# Use the proxy instead of the local socket
- "--providers.docker.endpoint=tcp://root-socket-proxy:2375"
networks:
socket: {}
```
## Security Monitoring (CrowdSec) {#crowdsec}
The framework integrates **CrowdSec** for behavioral detection and automated response.
- **Intrusion detection**: CrowdSec monitors logs from [Traefik](https://traefik.io/) and SSH for suspicious patterns (e.g., brute-force attacks, port scanning).
- **Automated blocking**: When a threat is detected, the **CrowdSec Firewall Bouncer** automatically updates [UFW](https://help.ubuntu.com/community/UFW) rules to block the malicious IP at the host level.
- **Shared threat intelligence**: CrowdSec shares anonymized threat data with its community, providing preemptive protection against known bad actors.
## Network hardening {#networking}
Root uses `configure/ufw` to implement a "Default Deny" policy.
- **Internal routing**: Services communicate over a dedicated `root-proxy` bridge network, which is often configured as `internal: true`.
- **Enclave isolation**: Outgoing traffic can be routed through a dedicated egress node for granular control.
---
**See also:**
- [Concept: Tiered storage](/root/concepts/tiered-storage)
- [Guide: Action development](/root/guides/action-development)
@@ -0,0 +1,78 @@
---
title: Tiered storage model
description: Standardized storage taxonomy for service data.
weight: 15
---
The framework enforces a standardized storage taxonomy. This ensures that every service has a predictable location for its data, which simplifies backup strategies and hardware allocation.
## The storage taxonomy {#taxonomy}
Data is divided into three logical [Tiers](/root/glossary#tier) based on its performance requirements, lifecycle, and importance.
### Tier 1: Configuration & Cache
**Path:** `${ROOT_NODE_DIR}/@tier1`
- **Contents**: Config files, small SQLite databases, SSL certificates, application cache.
- **Characteristics**: Extremely high value, low to medium volume, requires low latency.
- **Hardware**: High-speed SSD storage, typically shared with the Operating System.
### Tier 2: Application Data
**Path:** `${ROOT_NODE_DIR}/@tier2`
- **Contents**: User uploads, large databases, persistent application state.
- **Characteristics**: High value, medium to high volume, requires high reliability.
- **Hardware**: Reliable HDD clusters, RAID arrays, or dedicated storage compute.
### Tier 3: Transients & Bulk Media
**Path:** `${ROOT_NODE_DIR}/@tier3`
- **Contents**: Logs, temporary files, bulk media (movies, music), and other non-critical large data.
- **Characteristics**: Low value, very high volume.
- **Hardware**: High-capacity HDDs optimized for continuous sequential write/read (e.g., WD Purple). Note that Tier 3 is **too slow for cache**.
## Backup strategy {#backup}
The framework does not enforce a specific backup strategy or provide a default implementation, allowing for complete node-specific flexibility. Operators are expected to implement their own backup logic.
### Example: Restic backup
A common best practice is to use **Restic** for backups. For example, you might create an operator compose file to run Restic:
```yaml filename="operator-compose.yml"
services:
restic:
image: restic/restic:0.18.1
container_name: operator-restic
volumes:
- ./@tier2/{project}/{service}/{data}:/data/@tier2/{project}/{service}/{data}:ro
- ./@tier1/{project}/{service}/{data}:/data/@tier1/{project}/{service}/{data}:ro
- ./@tier1/.operator/restic/cache:/var/restic-cache
environment:
RESTIC_REPOSITORY: "s3:example.com/bucket"
RESTIC_PASSWORD: ${RESTIC_PASSWORD}
# ... AWS/S3 credentials
network_mode: "host"
entrypoint: [ "restic" ]
user: "0:0"
```
### Implementing the strategy
Operators can implement this strategy by defining a local action in `.operator/local/scripts/actions/sync/tiers`. The `root` dispatcher can then execute this script to trigger the backup, allowing for granular control over how data is persisted.
## Justification {#justification}
1. **Hardware optimization**: By mapping tiers to specific hardware (SSDs for Tier 1, RAID for Tier 2, Purples for Tier 3), we optimize for both performance and cost.
2. **Selective backup**: Separating transient logs and bulk media (Tier 3) from critical configs (Tier 1) prevents wasting expensive backup storage.
3. **Architectural clarity**: Any operator can inspect a [Node](/root/glossary#node) directory and immediately understand the storage requirements and priority of its contents.
---
**See also:**
- [Concept: Infrastructure architecture](/root/concepts/architecture)
- [Reference: ROOT_ API](/root/reference/root-api)
+41
View File
@@ -0,0 +1,41 @@
---
title: Glossary
description: Terminology used within the Root infrastructure framework.
weight: 100
---
Terminology used within the Root infrastructure framework.
## Action {#action}
**Action** - An atomic automation task located in `scripts/actions/`. Actions follow the "Stupid Action" philosophy, remaining declarative and relying on the `root` dispatcher for environment setup.
## Commons {#commons}
**Commons** - The shared repository containing generic logic, namespaced libraries, and standardized automation actions used across all nodes. Referenced at [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons).
## Dispatcher {#dispatcher}
**Dispatcher** - The `root` script that serves as the entrypoint. It resolves paths, warms the environment, and executes the requested [Action](/root/glossary#action).
## Node {#node}
**Node** - A physical or virtual host managed by the framework. Each node has a dedicated directory in the configuration repository containing its services and local settings.
## Root {#root}
**Root** - Both the name of the command-line utility and the top-level orchestration service within a [Node](/root/glossary#node) (typically handling Traefik and Adguard).
## Tier {#tier}
**Tier** - A standardized storage classification for service data.
- **Tier 1**: Configuration and critical state.
- **Tier 2**: Application data and databases.
- **Tier 3**: Logs, caches, and bulk media.
---
**See also:**
- [Concept: Infrastructure architecture](/root/concepts/architecture)
- [Concept: Tiered storage model](/root/concepts/tiered-storage)
@@ -0,0 +1,13 @@
---
title: Guides
description: Step-by-step guides for installation and action development.
weight: 20
---
Step-by-step guides for installing and developing within the ermnvldmr.com root deployment commons framework.
{{% cards %}}
{{< card link="first-node" title="Setting Up Your First Node" icon="map" subtitle="Practical guide to creating your own config repository." >}}
{{< card link="installation" title="Installation" icon="arrow-down" subtitle="Initial setup of the framework on a new host." >}}
{{< card link="action-development" title="Action development" icon="code" subtitle="Creating and managing atomic automation tasks." >}}
{{% /cards %}}
@@ -0,0 +1,67 @@
---
title: Action development
description: Developing atomic automation tasks in the Root framework.
weight: 30
---
[Actions](/root/glossary#action) are atomic automation tasks. Following the "Stupid Action" philosophy, they must remain declarative and rely on the library for complex operations.
## Creating a new action {#create}
{{% steps %}}
### Step 1: Create the script file
Place your script in `scripts/actions/{group}/{name}`. Do not use file extensions.
### Step 2: Implement metadata
Every action must provide a description and argument list for the help system.
```bash filename="scripts/actions/utils/my-action"
action_metadata() {
cat <<EOF
description: "Brief summary of the task."
args:
- NODE: "The node name"
- PARAM: "A custom parameter"
EOF
}
```
### Step 3: Initialize context
Use the `root_require_node` helper to establish the environment. This must be the first functional step in your script.
```bash filename="scripts/actions/utils/my-action"
root_require_node "${1:-}"
shift
```
### Step 4: Use namespaced helpers
Perform operations using the `root_*` library functions. Rely on `ROOT_` environment variables for all pathing.
```bash filename="scripts/actions/utils/my-action"
root_log_info "Processing node $ROOT_NODE"
root_sys_restart_service "my-service"
```
{{% /steps %}}
## Library namespaces {#libraries}
Actions have access to the following modules:
- `root_core_*`: Logging and Telegram notifications.
- `root_init_*`: Declarative service initialization and package management.
- `root_sys_*`: Systemd and unit file management.
- `root_template_*`: Envsubst-based rendering and configuration injection.
- `root_validate_*`: Input, file, and OS validation.
---
**See also:**
- [Reference: ROOT_ API reference](/root/reference/root-api)
- [Concept: Infrastructure architecture](/root/concepts/architecture)
@@ -0,0 +1,190 @@
---
title: "Setting Up Your First Node"
description: A practical guide on creating your first node and hooking up the deployment commons.
weight: 25
---
**Goal:** Create a configuration repository for your infrastructure, add the root deployment commons as a submodule, and define your first basic node.
## 1. Creating the Configuration Repository
Your infrastructure should be managed from a single Git repository. This repository will hold the configuration for all your servers (nodes).
{{% steps %}}
### Step 1: Initialize the repository
Create a new directory and initialize Git.
```bash
mkdir my-infrastructure
cd my-infrastructure
git init
```
### Step 2: Add the commons submodule
The `ermnvldmr.com-root-commons` must be placed at `.operator/shared`.
```bash
mkdir -p .operator
git submodule add https://github.com/deytenit/ermnvldmr.com-root-commons.git .operator/shared
```
### Step 3: Create the Bootstrap Script
Create an `init.sh` file at the root of your repository. This script links the commons framework into your local environment.
```bash filename="init.sh"
#!/usr/bin/env bash
# init.sh
# Source common utilities from the submodule
source "$(git -C "$(dirname "$0")" rev-parse --show-toplevel)/.operator/shared/scripts/lib/common.sh"
SCRIPT_NAME="init"
navigate_to_repo_root
log_info "Setting up Git hooks..."
git config core.hooksPath .operator/shared/githooks
git config --global --add safe.directory "${REPO_ROOT}"
log_info "Setting up PATH for the root dispatcher..."
ABSOLUTE_RUN_DIR="$(realpath "${REPO_ROOT}/.operator/shared/scripts/run")"
BASHRC_ENTRY="export PATH=\"${ABSOLUTE_RUN_DIR}:\$PATH\""
sed -i '/\.operator\/shared\/scripts\/run/d' ~/.bashrc
echo "$BASHRC_ENTRY" >> ~/.bashrc
log_info "Done. Please run 'source ~/.bashrc' or re-login to your shell."
```
Make it executable:
```bash
chmod +x init.sh
```
{{% /steps %}}
## 2. Defining Your First Node
A Node is a folder at the repository root that represents one physical or virtual server.
### Node Structure
Let's create a node named `daedalus`. Inside, we'll define an operator configuration (e.g., for backups) and a core service.
```bash
mkdir -p daedalus/.operator/configs/{cron,crowdsec,systemd,ufw/docker,ufw/host}
mkdir -p daedalus/root
```
### Base Configurations (Optional)
You can place configuration files (like UFW rules or a crontab) inside the `daedalus/.operator/configs/` directory. For example, a basic crontab:
```bash filename="daedalus/.operator/configs/cron/crontab.template"
# Run backups every night at 3:00 AM
0 3 * * * root sync tiers daedalus
```
## 3. Configuring Services (Docker Compose)
The framework relies on `docker-compose.yml` to orchestrate services.
### Node-Level Infrastructure (Operator)
The `daedalus/docker-compose.yml` file runs infrastructure utilities, such as `restic` for backups or VPN agents. This compose file has direct access to the filesystem via `@tierX` mounts.
```yaml filename="daedalus/docker-compose.yml"
name: 'operator'
services:
restic:
image: restic/restic:0.18.1
container_name: operator-restic
volumes:
# Example: mounting project data for backups (Read-Only)
- ./@tier2/my-app/data:/data/@tier2/my-app/data:ro
- ./@tier3/.operator/restic/cache:/var/restic-cache
environment:
RESTIC_REPOSITORY: "s3:example.com/my-bucket/daedalus"
RESTIC_PASSWORD: "${RESTIC_PASSWORD?error}"
# AWS credentials...
network_mode: "host"
entrypoint: [ "restic" ]
```
### The Root Project
The `root` project typically contains your reverse proxies (Traefik) and core services that other projects will route through.
```yaml filename="daedalus/root/docker-compose.yml"
name: root
services:
socket-proxy:
image: tecnativa/docker-socket-proxy:0
container_name: root-socket-proxy
user: "0:0"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
CONTAINERS: 1
SERVICES: 1
TASKS: 1
networks:
socket: {}
restart: unless-stopped
traefik:
image: traefik:3.6
container_name: root-traefik
# UID and GID are automatically populated by the framework after running 'root tiers useradd'
user: "${UID?error}:${GID?error}"
group_add:
- "${SHARED_GID?error}"
depends_on:
- socket-proxy
volumes:
- ./@tier1/shared/root/traefik/letsencrypt:/letsencrypt
- ./@tier3/traefik/logs:/logs
- ./@tier1/traefik/dynamic:/etc/traefik/dynamic:ro
ports:
- '0.0.0.0:80:80'
- '0.0.0.0:443:443'
networks:
proxy: {}
socket: {}
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.network=root-proxy"
- "--providers.docker.endpoint=tcp://root-socket-proxy:2375"
restart: unless-stopped
networks:
proxy:
name: root-proxy
internal: true
driver: bridge
socket:
name: root-socket
internal: true
driver: bridge
```
## 4. Deployment
Now that your structure is ready:
1. Commit and push this repository to your Git server (e.g., GitHub).
2. SSH into your VPS.
3. Follow the [Installation](/root/guides/installation) guide, using your new repository URL instead of the example ones.
---
**See also:**
- [Guide: Installation on a new host](/root/guides/installation)
- [Concept: Infrastructure Architecture](/root/concepts/architecture)
@@ -1,6 +1,6 @@
---
title: 'Initial Host Setup'
description: 'Setting up a fresh Debian host from scratch.'
title: Initial Host Setup
description: Setting up a fresh Debian host from scratch.
weight: 1
---
@@ -41,10 +41,10 @@ groupadd wheel
# Create a modular sudoers file
# This allows members of 'wheel' to run sudo without a password
echo "%wheel ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/99-root-ermnvldmr
echo "%wheel ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/99-custom-sudoers
# Validate syntax
visudo -c -f /etc/sudoers.d/99-root-ermnvldmr
visudo -c -f /etc/sudoers.d/99-custom-sudoers
# Add adam to the necessary groups
usermod -aG wheel,docker adam
@@ -92,10 +92,10 @@ Configure your local machine for easy access to the host.
```bash
# ~/.ssh/config on operator side
Host daedalus.root.ermnvldmr.com
Host daedalus.example.com
Port 2222
User adam
IdentityFile ~/.ssh/adam_root_ermnvldmr_com_ed25519
IdentityFile ~/.ssh/adam_example_com_ed25519
```
**Harden SSH Daemon**
@@ -105,7 +105,7 @@ Modify the SSH configuration to change the default port and disable password ent
```bash
# As root:
# Create a dedicated override config
cat <<EOF > /etc/ssh/sshd_config.d/99-root-ermnvldmr.conf
cat <<EOF > /etc/ssh/sshd_config.d/99-custom-ssh.conf
Port 2222
PasswordAuthentication no
PermitEmptyPasswords no
@@ -137,21 +137,21 @@ cat /home/adam/.ssh/github_ed25519.pub
```
> [!TIP]
> Add the output above to the [GitHub: Repository Deploy Keys](https://github.com/deytenit/root.ermnvldmr.com/settings/keys) with **read access**.
> Add the output above to the Deploy Keys settings of your infrastructure repository (e.g., `https://github.com/your-username/your-repo/settings/keys`) with **read access**.
### Repository Deployment
Clone the infrastructure repository and initialize the node.
Clone your infrastructure repository and initialize the node.
```bash
# As root:
# Setup the service directory
export REPO_DIR="/srv/root.ermnvldmr.com"
export REPO_DIR="/srv/your-infrastructure-repo"
mkdir -p $REPO_DIR
chown adam:adam $REPO_DIR
# Clone as the adam user
sudo -u adam git clone git@github.com:deytenit/root.ermnvldmr.com.git $REPO_DIR
sudo -u adam git clone git@github.com:your-username/your-repo.git $REPO_DIR
# Initialize node (Set your node name, e.g., daedalus)
export NODE="daedalus"
@@ -161,16 +161,15 @@ sudo -u adam ./init.sh
**Node-Specific Configuration**
Run the staged initialization scripts in order:
Run the staged initialization scripts using the `root` dispatcher:
```bash
# As root:
# Run the sequence for your specific node
cd $REPO_DIR
./$NODE/.host/.scripts/00-base
./$NODE/.host/.scripts/10-ufw
./$NODE/.host/.scripts/20-crowd
./$NODE/.host/.scripts/30-cron
# Run the sequence for your specific node (as the 'adam' user)
# Ensure you have sourced ~/.bashrc or re-logged in if 'root' is not found
root configure base $NODE
root configure ufw $NODE
root configure crowdsec $NODE
root configure cron $NODE
```
### Storage & Hardening
@@ -178,18 +177,17 @@ cd $REPO_DIR
Define the data tiers and secure the environment by creating non-root users for containerized services.
```bash
# As root:
# Create (or mount) storage directories
mkdir -p /srv/com-ermnvldmr-root-$NODE-{tier1,tier2,tier3}
sudo mkdir -p /srv/my-infrastructure-root-$NODE-{tier1,tier2,tier3}
# Setup tiers and non-root users
cd $REPO_DIR
sudo -u adam .scripts/ops/setup-tiers $NODE \
/srv/com-ermnvldmr-root-$NODE-tier1 \
/srv/com-ermnvldmr-root-$NODE-tier2 \
/srv/com-ermnvldmr-root-$NODE-tier3
root tiers link $NODE \
/srv/my-infrastructure-root-$NODE-tier1 \
/srv/my-infrastructure-root-$NODE-tier2 \
/srv/my-infrastructure-root-$NODE-tier3
sudo -u adam .scripts/ops/setup-noroot-users $NODE
root tiers useradd $NODE
root tiers chown $NODE
```
{{% /steps %}}
@@ -199,4 +197,3 @@ sudo -u adam .scripts/ops/setup-noroot-users $NODE
**See also:**
- [Doc: Docker - Install on Debian](https://docs.docker.com/engine/install/debian/)
- [GitHub: root.ermnvldmr.com](https://github.com/deytenit/root.ermnvldmr.com)
@@ -1,19 +0,0 @@
---
title: 'Recipes'
description: 'Collection of step-by-step procedures for infrastructure maintenance and deployment.'
weight: 50
---
Step-by-step instructions for reproducible tasks and infrastructure maintenance.
## Available Recipes
{{% cards %}}
{{< card link="initial-setup" title="Initial Host Setup" icon="server" subtitle="Hardened setup for a fresh Debian host from zero to deployment." >}}
{{% /cards %}}
---
**See also:**
- [GitHub: root.ermnvldmr.com](https://github.com/deytenit/root.ermnvldmr.com)
@@ -0,0 +1,18 @@
---
title: Reference
description: API and technical references for developers.
weight: 30
---
Technical reference materials and APIs for the ermnvldmr.com root deployment commons framework.
{{% cards %}}
{{< card link="compose" title="Compose actions reference" icon="play" subtitle="High-level entrypoint for orchestrating docker-compose." >}}
{{< card link="configure" title="Configure actions reference" icon="arrow-down" subtitle="Host provisioning and system-level configuration." >}}
{{< card link="initialization" title="Initialization actions reference" icon="play" subtitle="Actions for initial node setup." >}}
{{< card link="root-api" title="ROOT_ API reference" icon="code" subtitle="Standardized environment variables and logging helpers." >}}
{{< card link="source" title="Source services reference" icon="book-open" subtitle="Shared services built from source." >}}
{{< card link="sync" title="Sync actions reference" icon="check" subtitle="Actions for keeping nodes up-to-date." >}}
{{< card link="tiers" title="Tiers actions reference" icon="archive" subtitle="Storage tiers management." >}}
{{< card link="utils" title="Utility actions reference" icon="check" subtitle="Helper scripts and utilities." >}}
{{% /cards %}}
@@ -0,0 +1,50 @@
---
title: Compose Actions
description: Standardized high-level entrypoint for orchestrating docker-compose across a node.
weight: 54
---
The `compose` action provides a standardized, high-level entrypoint for orchestrating Docker Compose files across all projects within a specific node.
## Core Compose Action {#actions}
| Action | Description |
| :--- | :--- |
| `compose <node> <action>` | Executes a given Docker Compose command (e.g., `up`, `down`, `restart`) across the node's infrastructure. |
## Orchestration Order {#order}
To maintain correct dependencies (especially when shutting down or starting up infrastructure-level proxies and networks), the `compose` action intelligently orders the execution:
### Starting Up (`up`, `restart`)
When starting or restarting services, the action follows a **Forward Order**:
1. **Node Infrastructure**: First, it processes the node-level `docker-compose.yml` (located at the root of the node directory).
2. **Root Infrastructure**: Next, it processes the `root/docker-compose.yml` project, if it exists (usually contains Traefik, Socket Proxy, etc.).
3. **Projects**: Finally, it processes all other project directories in alphabetical order.
### Shutting Down (`down`)
When stopping services, the action uses a **Reverse Order** to ensure projects disconnect gracefully before the network or proxy goes down:
1. **Projects**: All standard projects are stopped in reverse alphabetical order.
2. **Root Infrastructure**: The `root/docker-compose.yml` project is stopped.
3. **Node Infrastructure**: Finally, the node-level `docker-compose.yml` is brought down.
## Options {#options}
You can pass standard Docker Compose arguments to the end of the command.
Additionally, the action supports a dry-run mode:
- `--dry-run`: Prints the commands that would be executed without actually running them.
**Example usage:**
```bash
root compose daedalus up -d --remove-orphans
root compose daedalus restart --dry-run
root compose daedalus down
```
---
**See also:**
- [Concept: Infrastructure Architecture](/root/concepts/architecture)
- [Reference: ROOT_ API Reference](/root/reference/root-api)
@@ -0,0 +1,66 @@
---
title: Configuration Actions
description: Host provisioning, template engine, and system-level configuration.
weight: 53
---
Configuration actions are used to provision the host and maintain system-level services. They rely heavily on the built-in template engine to generate declarative configuration files.
## The Template Engine {#template-engine}
The Root framework uses `envsubst` (from `gettext-base`) to render templates. This allows you to use standard Bash environment variables directly within your configuration files.
### Template Conventions
- **File Extension**: Templates typically use the `.template` extension (e.g., `crontab.template`).
- **Variable Syntax**: Use `${VARIABLE_NAME}` or `$VARIABLE_NAME` to inject values.
- **Auto-Discovery**: Many actions automatically look for templates in specific directories within the [Configs](/root/glossary#configs) repository.
### Rendering Helpers
The `root_template.sh` library provides several helpers:
- `root_template_render <input> <output>`: Renders a single file.
- `root_template_render_directory <src> <dest>`: Recursively renders an entire directory structure.
- `root_template_crontab <template>`: Renders and installs a crontab for the current user.
## Core Configuration Actions {#actions}
| Action | Description |
| :--- | :--- |
| `configure/base` | Installs essential packages (`sudo`, `ufw`, `docker`) and sets up basic system users. |
| `configure/cron` | Renders and installs node-specific crontabs. |
| `configure/crowdsec` | Sets up the CrowdSec agent and bouncers. |
| `configure/routing` | Configures system-level routing and bridge networks. |
| `configure/systemd` | Installs and enables systemd service units. |
| `configure/ufw` | Renders and applies firewall rules using UFW and `ufw-docker`. |
## Example: Nginx Template {#example}
```nginx filename="nginx.conf.template"
# This template uses ROOT_NODE environment variable
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Injected from environment
server_name ${ROOT_NODE}.example.com;
}
```
---
**See also:**
- [Concept: Infrastructure Architecture](/root/concepts/architecture)
- [Reference: ROOT_ API Reference](/root/reference/root-api)
@@ -0,0 +1,40 @@
---
title: System Initialization
description: Repository setup, Git hooks, and PATH configuration.
weight: 51
---
The Root framework requires a one-time initialization on each host to establish path linkages and repository-level configurations.
## The `init.sh` Script {#init-script}
The `init.sh` script located at the repository root is the primary bootstrap mechanism. It performs the following critical tasks:
1. **Submodule Initialization**: Ensures the [Commons](/root/glossary#commons) repository is present and up to date.
2. **Git Hook Configuration**: Points `core.hooksPath` to `.operator/shared/githooks` to enable standardized pre-commit checks.
3. **Safe Directory Registration**: Adds the repository to Git's `safe.directory` list, which is required for operations performed by different users (e.g., `root`, `adam`).
4. **Vim Configuration**: Links a standardized `.vimrc` to both the current user and the `root` user for a consistent editing experience.
5. **PATH Persistence**: Adds the `.operator/shared/scripts/run` directory to the user's `~/.bashrc`, making the `root` command available globally.
## Git Hooks {#githooks}
Root enforces standards through Git hooks stored in `.operator/shared/githooks/`.
- **Pre-commit**: Validates that all scripts follow the namespacing and structure conventions.
- **Commit-msg**: (Optional) Enforces conventional commit messages if configured.
## PATH Configuration {#path}
The `root` command is a wrapper located in `.operator/shared/scripts/run/root`. By adding this directory to the `PATH`, the dispatcher can be invoked from any directory within the system, automatically resolving the repository root based on the current working directory or environment variables.
```bash
# Example of manual PATH addition (performed automatically by init.sh)
export PATH="$HOME/repo/.operator/shared/scripts/run:$PATH"
```
---
**See also:**
- [Guide: Initial Host Setup](/root/guides/installation)
- [Reference: ROOT_ API Reference](/root/reference/root-api)
@@ -0,0 +1,69 @@
---
title: ROOT_ API reference
description: Standardized environment variables for path resolution and node context.
weight: 50
---
Every [Action](/root/glossary#action) script executed via the `root` dispatcher has access to a standardized set of environment variables and Bash libraries. This provides a consistent "API" for interacting with the infrastructure.
## The `root` Dispatcher {#dispatcher}
The `root` command (located in `.operator/shared/scripts/run/root`) is the main entrypoint for all orchestration. Its primary responsibilities include:
1. **Context Resolution**: Locating the target [Node](/root/glossary#node) and its associated storage [Tiers](/root/glossary#tier).
2. **Library Loading**: Sourcing the core namespaced libraries (e.g., `root_core`, `root_sys`).
3. **Action Execution**: Discovering and running the requested action, prioritizing local overrides.
## Path resolution variables {#paths}
These variables are established by the dispatcher and are available to all actions.
| Variable | Description |
| :--- | :--- |
| `ROOT_SHARED` | Absolute path to the [Commons](/root/glossary#commons) repository. |
| `ROOT_CONFIGS` | Absolute path to the main configuration repository. |
| `ROOT_LOCAL` | Absolute path to the local operator directory (`.operator/local`). |
| `ROOT_ACTIONS_SHARED` | Path to the global shared actions. |
| `ROOT_ACTIONS_LOCAL` | Path to node-specific local actions. |
## Node context variables {#node-context}
When an action targets a specific node, the following variables are established by the `root_require_node` helper.
| Variable | Description |
| :--- | :--- |
| `ROOT_NODE` | The identifier of the target node (e.g., `daedalus`). |
| `ROOT_NODE_DIR` | Absolute path to the node configuration directory. |
| `ROOT_TIER1` | Absolute path to the [Tier 1](/root/glossary#tier) storage directory. |
| `ROOT_TIER2` | Absolute path to the Tier 2 storage directory. |
| `ROOT_TIER3` | Absolute path to the Tier 3 storage directory. |
## Core Logging Helpers {#logging}
Standardized logging ensure consistent output across all actions.
- `root_log_info`: Standard informational output.
- `root_log_warn`: Warnings requiring attention.
- `root_log_error`: Critical errors sent to `stderr`.
- `root_log_success`: Confirmation of successful task completion.
## The Help System {#help}
The dispatcher provides a built-in help system by parsing an `action_metadata` block at the top of each script. This allows scripts to remain "stupid" and declarative without implementing their own CLI argument parsing.
```bash filename="example-action-metadata"
action_metadata() {
cat <<EOF
description: "Brief summary of the action."
args:
- NODE: "The name of the target node."
EOF
}
```
---
**See also:**
- [Concept: Infrastructure principles](/root/concepts/principles)
- [Guide: Action development](/root/guides/action-development)
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more