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
125 changed files with 7163 additions and 1079 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:**
+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.
-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",
+3 -1
View File
@@ -22,7 +22,9 @@
"default": "./src/testing.ts"
}
},
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"build": "rsbuild build",
"test": "vitest run",
+1 -1
View File
@@ -35,7 +35,7 @@ export function localeRsbuildConfig(): RsbuildConfig {
// The canonical import is always `.en.mdx`; other locales are resolved here.
api.modifyRspackConfig((config, { rspack }) => {
config.plugins = [
...(config.plugins ?? []),
...config.plugins,
new rspack.NormalModuleReplacementPlugin(
/\.en\.mdx$/,
(resource: { request: string }) => {
+7 -3
View File
@@ -8,7 +8,12 @@ const { t, tRaw } = createKeyset({
'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} элементов' },
ru: {
none: 'нет элементов',
one: 'один элемент',
some: '{count} элемента',
many: '{count} элементов',
},
},
});
@@ -17,8 +22,7 @@ describe('t', () => {
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'));
it('pluralizes many with interpolation', () => expect(t('items', { count: 5 })).toBe('5 items'));
});
describe('tRaw', () => {
+8 -2
View File
@@ -4,10 +4,16 @@ 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 }
interface PlainTranslation {
en: string;
ru: string;
}
/** A translation entry with plural forms per locale. */
interface PluralTranslation { en: PluralForms; ru: PluralForms }
interface PluralTranslation {
en: PluralForms;
ru: PluralForms;
}
/** Union of all supported translation shapes. */
type Translation = PlainTranslation | PluralTranslation;
+2 -4
View File
@@ -5,8 +5,7 @@ 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 root path', () => expect(localePath('/')).toBe('/en/'));
it('handles nested path', () =>
expect(localePath('/articles/2026/test')).toBe('/en/articles/2026/test'));
});
@@ -14,8 +13,7 @@ describe('localePath', () => {
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('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'));
});
@@ -122,10 +122,10 @@ export function ssgPlugin(): RsbuildPlugin {
const critters = new Critters({
path: webDistPath,
publicPath: configuredAssetPrefix,
preload: 'swap', // <link rel="preload" as="style" onload="this.rel='stylesheet'">
inlineFonts: false, // fonts are handled via separate <link rel="preload"> tags
pruneSource: false, // keep full CSS file for post-hydration correctness
reduceInlineStyles: false, // preserve theme init styles (prevents .dark rule from being stripped)
preload: 'swap', // <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) {
+1 -3
View File
@@ -4,9 +4,7 @@ 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 rbFontSizeClasses = RB_TYPES.flatMap((type) => RB_SIZES.map((size) => `${type}-${size}`));
const twMerge = extendTailwindMerge({
extend: {
+6 -2
View File
@@ -38,8 +38,12 @@ export function createLocalStorage<T>(key: string, defaultValue: T): StorageMana
return {
key,
get: () => defaultValue,
set: () => { /* no-op in SSR/SSG context */ },
remove: () => { /* no-op in SSR/SSG context */ },
set: () => {
/* no-op in SSR/SSG context */
},
remove: () => {
/* no-op in SSR/SSG context */
},
};
}
+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"
}
}
+12 -4
View File
@@ -30,11 +30,19 @@ if (typeof window.localStorage.clear !== 'function') {
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(); },
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; },
get length(): number {
return store.size;
},
},
});
}
@@ -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;
}
@@ -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,28 +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(
'grid transition-[grid-template-rows] duration-300 ease-in-out',
isCollapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'
'transition-opacity duration-300',
isCollapsible && isCollapsed ? 'opacity-0 pointer-events-none' : 'opacity-100'
)}
inert={isCollapsed || undefined}
>
<div className="overflow-hidden">{expandedContent}</div>
{expandedContent}
</div>
<div
className={cn(
'grid transition-[grid-template-rows] duration-300 ease-in-out',
isCollapsed ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
inert={!isCollapsed || undefined}
>
<div className="overflow-hidden">{collapsedContent}</div>
</div>
</header>
<div style={{ height }} />
</>
)}
{/* 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>
);
};
+25 -5
View File
@@ -132,29 +132,49 @@ describe('components/Text', () => {
// 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>);
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>);
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>);
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>);
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>);
render(
<Text size="l" type="display">
Display
</Text>
);
expect(screen.getByText('Display')).toHaveClass('font-serif');
});
});
+13 -1
View File
@@ -119,7 +119,19 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
{...props}
ref={internalRef}
className={cn(
getTypographyClassNames({ type, size, color, bold, italic, underline, strike, align, wrap, overflow, maxLines }),
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';
@@ -7,8 +7,12 @@ describe('lib/typography/getTypographyClassNames', () => {
});
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: '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');
@@ -54,7 +58,9 @@ describe('lib/typography/getTypographyClassNames', () => {
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: 'primary' })).toContain(
'text-[var(--rb-primary-text)]'
);
expect(getTypographyClassNames({ color: 'error' })).toContain('text-[var(--rb-error-text)]');
expect(getTypographyClassNames({ color: 'inherit' })).toContain('text-inherit');
});
+104 -35
View File
@@ -191,29 +191,62 @@
@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;
--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;
--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;
--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;
--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;
--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 {
@@ -285,34 +318,70 @@
@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-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-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);
--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)); }
.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 {
+1409 -489
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)
@@ -0,0 +1,42 @@
---
title: The `source/` Directory
description: Conceptual overview of the self-contained source directory structure.
weight: 52
---
The `source/` directory at the repository root contains the core components and projects that the Root framework orchestrates. It is designed for maximum isolation and self-containment.
## Core Philosophy {#philosophy}
The `source/` directory is not just a place to store code; it is a collection of independent [Projects](/root/glossary#project). Each project within this directory should follow these principles:
1. **Isolation**: Each project lives in its own subdirectory (e.g., `source/my-app/`).
2. **Self-Documentation**: Each project directory **MUST** contain its own `README.md` file describing its purpose, dependencies, and configuration.
3. **Internal Logic**: Projects handle their own build processes, Dockerfiles, and internal configurations. The Root framework's [Actions](/root/glossary#action) simply orchestrate these existing components.
## Directory Structure {#structure}
A typical project within the `source/` directory might look like this:
{{< filetree/container >}}
{{< filetree/folder name="source" state="open" >}}
{{< filetree/folder name="happ-subscription-generator" state="open" >}}
{{< filetree/file name="Dockerfile" >}}
{{< filetree/file name="generator.py" >}}
{{< filetree/file name="README.md" >}}
{{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< /filetree/container >}}
## Relationship with Actions {#actions}
Root actions (like `compose` or `sync`) interact with the `source/` directory by referencing paths within it. For example, the `compose` action might use a `docker-compose.yml` file that points to a `Dockerfile` located in `source/app/`.
This separation of concerns ensures that the orchestration logic (Actions) remains decoupled from the implementation details (Source).
---
**See also:**
- [Concept: Infrastructure Architecture](/root/concepts/architecture)
- [Reference: ROOT_ API Reference](/root/reference/root-api)
@@ -0,0 +1,36 @@
---
title: Synchronization Actions
description: Repository updates, package synchronization, and data backups.
weight: 54
---
Synchronization actions ensure that the host's state remains consistent with the defined configurations in the remote repositories.
## Core Synchronization Actions {#actions}
| Action | Description |
| :--- | :--- |
| `sync/packages` | Synchronizes node packages and their configurations across the system. |
| `sync/repository` | Updates the main configuration repository to its latest state from GitHub. |
| `sync/tiers` | Triggers the node-specific backup strategy for the configured [Tiers](/root/glossary#tier). |
## Repository Synchronization (`sync/repository`) {#repository}
The `sync/repository` action is responsible for keeping the host's configuration repository up-to-date with its remote source. It typically performs a `git pull` on the main configuration repository and may optionally trigger sub-module updates for the [Commons](/root/glossary#commons) repository.
This ensures that any changes to the node configurations, actions, or source files are correctly pulled down to the target host.
## Backup Synchronization (`sync/tiers`) {#tiers}
The `sync/tiers` action is the primary entry point for a node's backup strategy.
- **No Default Implementation**: The framework itself does not provide a default backup mechanism, allowing operators to design a custom strategy suitable for their needs.
- **Custom Overrides**: As a "Smart Dispatcher" action, you can define your backup logic by placing a custom script in `.operator/local/scripts/actions/sync/tiers`.
- **Example Implementation**: A common practice is to use a tool like [Restic](https://restic.net/) within this custom script to perform off-site backups of critical data (Tiers 1 and 2) to an S3-compatible storage backend.
---
**See also:**
- [Concept: Tiered Storage Model](/root/concepts/tiered-storage)
- [Reference: ROOT_ API Reference](/root/reference/root-api)
@@ -0,0 +1,44 @@
---
title: Tier Management Actions
description: Storage classification, symbolic linking, and privilege isolation.
weight: 55
---
Tier management actions are responsible for enforcing the framework's [Tiered Storage Model](/root/concepts/tiered-storage) and ensuring correct filesystem ownership and isolation.
## Core Tier Actions {#actions}
| Action | Description |
| :--- | :--- |
| `tiers/chown` | Sets correct filesystem ownership for project-specific [Non-Root Users](#useradd). |
| `tiers/link` | Manages symbolic links between the project directories and their corresponding [Tiers](/root/glossary#tier). |
| `tiers/useradd` | Provisions a unique system user for each project to achieve [Privilege Isolation](/root/concepts/security-model). |
## User Management (`tiers/useradd`) {#useradd}
The `tiers/useradd` action creates a dedicated, low-privileged system user for each project within a node. This user is then used to run the project's containers, ensuring that a compromise of one project does not grant the attacker access to the entire host.
- **Naming Convention**: Users are typically named after the project.
- **Group Membership**: Users can optionally be added to the `docker` group or interact with Docker via other securely configured methods, depending on the operator's preference.
## Storage Linking (`tiers/link`) {#link}
The `tiers/link` action creates the symbolic link structure required for the tiered storage model. It ensures that directories like `config`, `data`, and `logs` within a project folder point to their physical locations in `@tier1`, `@tier2`, and `@tier3` respectively.
```bash
# Example link structure created by tiers/link
/srv/root/icarus/synapse/config -> /srv/storage/@tier1/synapse
/srv/root/icarus/synapse/data -> /srv/storage/@tier2/synapse
/srv/root/icarus/synapse/logs -> /srv/storage/@tier3/synapse
```
## Ownership Management (`tiers/chown`) {#chown}
Once tiers and links are established, the `tiers/chown` action ensures that the physical directories in each tier are owned by the correct project user. This is critical for preventing permission-denied errors and ensuring that projects can only access their own data.
---
**See also:**
- [Concept: Tiered Storage Model](/root/concepts/tiered-storage)
- [Concept: Security & Privilege Isolation](/root/concepts/security-model)
@@ -0,0 +1,37 @@
---
title: Utility Actions
description: Common helper scripts for maintenance, validation, and certificate management.
weight: 56
---
Utility actions provide common helper functions for node maintenance, validation, and project-specific tasks.
## Core Utility Actions {#actions}
| Action | Description |
| :--- | :--- |
| `utils/extract-traefik-certs` | Extracts SSL/TLS certificates and private keys from Traefik's `acme.json` storage. |
| `utils/generate-happ-subscriptions` | Automatically generates HApp subscription files based on current project metadata. |
| `utils/lint-docker-compose` | Performs a static analysis of all `docker-compose.yml` files to ensure they meet the framework's standards. |
## Certificate Management (`utils/extract-traefik-certs`) {#certificates}
Traefik stores its ACME-issued certificates (like those from Let's Encrypt) in a single `acme.json` file. The `utils/extract-traefik-certs` action parses this file and extracts individual `fullchain.pem` and `privkey.pem` files.
This is particularly useful when other services (e.g., mail servers, non-Dockerized applications) require the same SSL certificates.
## Quality Assurance (`utils/lint-docker-compose`) {#linting}
To maintain consistency and security, the `utils/lint-docker-compose` action validates all compose files across the infrastructure. It checks for:
- **Naming Standards**: Ensures project names and container names follow the `{node}-{project}` convention.
- **Security Context**: Flags containers running as `root` without a justified override.
- **Resource Constraints**: Identifies services lacking CPU or memory limits.
- **Label Standards**: Validates required labels for routing (e.g., [Traefik](https://traefik.io/)) and [CrowdSec](/root/concepts/security-model#crowdsec).
---
**See also:**
- [Concept: Infrastructure Principles](/root/concepts/principles)
- [Concept: Security & Privilege Isolation](/root/concepts/security-model)
+2 -1
View File
@@ -7,5 +7,6 @@ title: 'Документация ermnvldmr.com'
Добро пожаловать в единую документацию для моих собственных проектов
{{< cards >}}
{{< card link="root" title="root.ermnvldmr.com" icon="server" subtitle="Документация инфраструктуры root.ermnvldmr.com" >}}
{{< card link="root" title="ermnvldmr.com-root-commons" icon="server" subtitle="Документация общих модулей корневой инфраструктуры ermnvldmr.com" >}}
{{< card link="quetza" title="Quetza Bot" icon="terminal" subtitle="Документация модульного фреймворка Discord-бота" >}}
{{< /cards >}}
+35
View File
@@ -0,0 +1,35 @@
---
title: Quetza Bot
description: Модульный фреймворк для Discord-ботов.
weight: 1
type: docs
cascade:
type: docs
---
**Quetza** — это модульный фреймворк для Discord-ботов, построенный на TypeScript, который позволяет разработчикам создавать многофункциональные приложения для Discord с помощью самодостаточных, композитных модулей.
Фреймворк обеспечивает архитектурный фундамент, который превращает разработку функций из модификации ядра в добавление модулей.
## Основные возможности
- **Обнаружение модулей на основе соглашений**: Просто создайте папку, и Quetza загрузит ее как функциональный модуль.
- **Изоляция контроллеров**: Управляйте состоянием на уровне сервера (guild), предотвращая утечки данных между серверами.
- **Декларативные команды**: Определяйте Slash-команды Discord с помощью простого, типизированного интерфейса.
- **Реактивные события**: Подписывайтесь на любые события Discord.js внутри ваших модулей.
## Разделы
{{% cards %}}
{{< card link="getting-started" title="Начало работы" icon="lightning-bolt" subtitle="Поймите философию и архитектуру Quetza." >}}
{{< card link="concepts" title="Концепции" icon="light-bulb" subtitle="Глубокое погружение в модульную систему и структуру контроллеров." >}}
{{< card link="guides" title="Руководства" icon="book-open" subtitle="Пошаговые инструкции по разработке модулей и деплою." >}}
{{< card link="troubleshooting" title="Устранение неполадок" icon="shield-exclamation" subtitle="Диагностика распространенных проблем в разработке модулей." >}}
{{% /cards %}}
---
**See also:**
- [GitHub: Quetza](https://github.com/deytenit/Quetza)
- [Discord.js Documentation](https://discord.js.org/)
@@ -0,0 +1,6 @@
---
title: Концепции
weight: 2
---
В этом разделе описываются архитектурные принципы, на которых построена Quetza. Понимание этих концепций поможет вам проектировать более надежные и поддерживаемые модули.
@@ -0,0 +1,45 @@
---
title: Архитектура и Философия
weight: 1
---
**Цель:** Понять основные принципы проектирования, лежащие в основе модульности Quetza.
## Философия проектирования
### Соглашение важнее конфигурации (Convention Over Configuration)
Quetza минимизирует шаблонный код, полагаясь на строгую структуру папок.
- **Никакого центрального реестра:** Сама файловая система _является_ реестром.
- **Результат:** Вам не нужно обновлять файл конфигурации, чтобы добавить модуль; вы просто создаете папку.
### Композиция важнее наследования (Composition Over Inheritance)
Модули состоят из возможностей (capabilities), а не наследуются от базового класса.
- **Гибкость:** Модули могут использовать любую внутреннюю архитектуру (Функциональную, ООП), пока они экспортируют необходимые интерфейсы.
### Изоляция важнее интеграции (Isolation Over Integration)
Модули не могут напрямую вызывать друг друга.
- **Почему:** Это предотвращает сильную связность (tight coupling).
- **Преимущество:** Вы можете удалить любой модуль, не сломав остальную часть бота.
## Основные сущности
| Сущность | Роль | Жизненный цикл |
| :--- | :--- | :--- |
| **Client** | Локатор сервисов и оркестратор | Синглтон. Руководит всем. |
| **Module** | Пространство имен для функций | Загружается при запуске. |
| **Command** | Конечная точка взаимодействия с пользователем | Без состояния (Stateless). Запрос/Ответ. |
| **Event** | Реактивный наблюдатель (Observer) | Подписывается при запуске. |
| **Controller** | Менеджер состояния | Сохраняется между взаимодействиями. |
---
**См. также:**
- [Паттерн Service Locator](https://ru.wikipedia.org/wiki/Локатор_служб)
- [Паттерн Observer](https://ru.wikipedia.org/wiki/Наблюдатель_(шаблон_проектирования))
@@ -0,0 +1,67 @@
---
title: Модульная система
weight: 2
---
**Цель:** Определить словарь и механизмы основной абстрактной единицы Quetza.
## Системный словарь
### Объявление модуля (Module Declaration)
Файл `module.ts` выступает в качестве манифеста. Это точка входа для обнаружения.
```typescript
export const name = 'my-module'; // ID
export const description = '...'; // Документация
```
### Регистрация команд (Command Registration)
Происходит во время события `ready`.
1. Quetza сканирует все папки `commands/`.
2. Компилирует `ApplicationCommandData`.
3. Отправляет (PUT) данные в API Discord.
### Внедрение контроллера (Controller Injection)
Состояние внедряется (injected), а не импортируется.
```typescript
// Фреймворк вызывает это:
execute(client, interaction, controller);
```
- **Зачем?** Позволяет фреймворку управлять жизненным циклом контроллера (синглтон на каждый модуль).
## Философия управления состоянием
### Эфемерность по умолчанию
Quetza **не предоставляет встроенной базы данных**.
- **Память:** Контроллеры хранят состояние в JS объектах `Map` или `Set`.
- **Перезапуск:** Все состояние теряется при завершении процесса.
- **Постоянство:** Должно быть реализовано вручную (например, SQLite, Redis) внутри Контроллера.
### Область видимости как ключ
Состояние должно быть ключировано по его границам.
- `Map<GuildID, ...>` - Специфично для сервера (Наиболее частое).
- `Map<UserID, ...>` - Специфично для пользователя (Боты для личных сообщений - DM).
## Модель обработки ошибок
**Быстрый и дружелюбный отказ (Fail Fast & Friendly).**
- **Валидация:** Проверяйте права/вводы немедленно.
- **Для пользователя:** "У вас нет прав" (а не "Ошибка: 403").
- **Изоляция:** Сбой в `/ping` перехватывается сам по себе и не "убивает" бота.
---
**См. также:**
- [Жизненный цикл взаимодействия Discord (Interaction Lifecycle)](https://discord.com/developers/docs/interactions/receiving-and-responding)
@@ -0,0 +1,66 @@
---
title: Начало работы
weight: 1
---
**Quetza** — это модульный фреймворк для Discord-ботов, построенный на TypeScript, который позволяет разработчикам создавать многофункциональные приложения для Discord с помощью самодостаточных, композитных модулей.
## Что такое Quetza?
Quetza предоставляет архитектурный фундамент, который превращает разработку функций из модификации ядра в добавление модулей. Он решает проблему жесткого дизайна ботов за счет инверсии структур зависимостей: ядро ничего не знает о модулях, однако модули имеют полный доступ к возможностям через определенные контракты.
## Основная философия
### Проблема
Традиционные монолитные боты страдают от хрупкости. Добавление функций требует изменения логики ядра, переплетения зависимостей и управления растущими базами кода, которые трудно тестировать или изменять.
### Решение
Quetza рассматривает модули как плагины.
- **Расширение без правки ядра:** Добавляйте функции, создавая папку. Модификация кода ядра не требуется.
- **Изоляция:** Модули работают независимо.
- **Композиция:** Включайте или выключайте функции, просто добавляя или удаляя папки модулей.
## Высокоуровневая архитектура
Quetza состоит из трех основных уровней:
1. **Базовый уровень (Foundation Layer)**: Клиент `Discord.js`, расширенный реестрами модулей (команды, события, модули). Он действует как сервис-локатор.
2. **Модульный уровень (Module Layer)**: Самодостаточные пакеты функций. Каждый модуль предоставляет собственные команды и события через стандартизированную структуру файлов.
3. **Сервисный уровень (Service Layer)**: Опциональные **Контроллеры**, которые управляют состоянием бизнес-логики (например, аудиоплееры, контекст разговора) на разных серверах (guilds).
## Ключевые концепции
### Ментальная модель
Для эффективной работы с Quetza примите следующую парадигму:
- **Модули — это плагины:** Независимые папки, обнаруживаемые во время выполнения.
- **Команды — это конечные точки (Endpoints):** Обработчики без сохранения состояния, действующие подобно эндпоинтам REST API.
- **События — это наблюдатели (Observers):** Реактивная логика, подписывающаяся на события жизненного цикла Discord.
- **Контроллеры — это сервисы:** Менеджеры состояния, восполняющие пробел между командами без состояния и постоянным поведением.
### Границы системы
Понимание ограничений критически важно для проектирования:
- **Отсутствие постоянства (Persistence):** Состояние в памяти теряется при перезагрузке. Для постоянного хранения требуется внешняя база данных.
- **Отсутствие межмодульного взаимодействия:** Модули не могут напрямую вызывать друг друга, что обеспечивает слабую связанность.
- **Отсутствие динамической загрузки:** Модули загружаются только при запуске.
## Архитектурные инварианты
Quetza жертвует гибкостью ради предсказуемости с помощью строгих соглашений:
- **Обнаружение на основе соглашений:** Модули _обязаны_ следовать структуре директорий (`module.ts`, `commands/`, `events/`) для загрузки.
- **Однофазная инициализация:** Вся загрузка происходит до установления соединения с Discord.
- **Соблюдение интерфейсов:** Все команды и события должны экспортировать специфические интерфейсы для их регистрации.
---
**See also:**
- [Discord.js Documentation](https://discord.js.org/)
- [TypeScript Documentation](https://www.typescriptlang.org/)
@@ -0,0 +1,6 @@
---
title: Руководства
weight: 3
---
Пошаговые инструкции и руководства по реализации функций в Quetza.
@@ -0,0 +1,56 @@
---
title: Интеграция разговорного ИИ
weight: 4
---
**Цель:** Интегрировать большие языковые модели (LLM), управляя контекстом и ограничениями тайм-аута Discord.
## 1. Стратегия управления контекстом
Разговоры требуют истории, чтобы быть связными.
- **Область видимости (Scoping):** `Map<GuildID, Map<UserID, Message[]>>`
- **Ключ гильдии (Guild Key):** Предотвращает утечки между серверами.
- **Ключ пользователя (User Key):** Предотвращает путаницу между пользователями в публичных каналах.
- **Жизненный цикл:** Реализуйте "скользящее окно" (например, последние 50 сообщений) для предотвращения разрастания контекста.
## 2. Потоковая передача ответов
LLM работают медленно, а Discord требует быстрых ответов (тайм-аут 3 секунды).
{{% steps %}}
### Шаг 1: Отложенное взаимодействие (Defer Interaction)
Немедленно вызовите `interaction.deferReply()`. Это даст вам 15 минут.
### Шаг 2: Вызов LLM
Асинхронно вызовите ваш ИИ-сервис.
### Шаг 3: Редактирование ответа
Когда данные поступят, используйте `interaction.editReply(content)`.
{{% /steps %}}
## 3. Пример реализации
```typescript
// commands/chat.ts
export async function execute(interaction, controller) {
// 1. Отложить ответ
await interaction.deferReply();
// 2. Обработка
const history = controller.getHistory(interaction.guildId, interaction.user.id);
const response = await aiService.generate(interaction.options.getString('prompt'), history);
// 3. Ответ
await interaction.editReply(response);
controller.updateHistory(interaction.guildId, interaction.user.id, response);
}
```
> [!WARNING]
> **Затраты на API:**
> API ИИ часто тарифицируются по токенам. Внедрите строгие лимиты запросов (rate limits) для пользователей в вашем Контроллере, чтобы предотвратить злоупотребления.
@@ -0,0 +1,77 @@
---
title: Развертывание и эксплуатация
weight: 2
---
**Цель:** Настроить, развернуть и мониторить экземпляр бота Quetza.
## 1. Подготовка окружения
Quetza полагается на переменные окружения для конфигурации. **Никогда** не сохраняйте их в систему контроля версий.
| Переменная | Описание | Обязательно |
| :--- | :--- | :--- |
| `DISCORD_TOKEN` | Токен бота из Discord Developer Portal | **Да** |
| `CLIENT_ID` | ID приложения для регистрации slash-команд | **Да** |
| `LLAMA_API_KEY` | Требуется при использовании модуля ИИ | Нет |
## 2. Стратегии развертывания
{{< tabs >}}
{{< tab name="Docker (Рекомендуется)" >}}
**Docker** обеспечивает стабильное окружение выполнения.
1. **Сборка** образа:
```bash
docker build -t quetza-bot .
```
2. **Запуск** контейнера:
```bash
docker run -d \
--env-file .env \
--name quetza \
quetza-bot
```
{{< /tab >}}
{{< tab name="Из исходников (Source)" >}}
Развертывание **из исходников** полезно для разработки.
1. **Установка** зависимостей:
```bash
pnpm install
```
2. **Сборка** проекта:
```bash
pnpm build
```
3. **Запуск** бота:
```bash
pnpm start
```
{{< /tab >}}
{{< /tabs >}}
## 3. Операционный мониторинг
После запуска используйте следующие механизмы для проверки работоспособности:
- **Логи:** Проверьте стандартный вывод (логи Docker) на наличие ошибок инициализации.
- **Команда `/ping`:** Проверяет задержку сети и отзывчивость API.
- **Команда `/modules`:** Перечисляет все загруженные модули, чтобы подтвердить доступность функций.
> [!WARNING]
> **Ограничение масштабирования:**
> Quetza следует модели "один экземпляр на один токен". Не запускайте несколько экземпляров с одним и тем же токеном, так как это вызовет конфликты WebSocket.
---
**См. также:**
- [Документация Docker](https://docs.docker.com/)
- [Discord Developer Portal](https://discord.com/developers/applications)
@@ -0,0 +1,105 @@
---
title: Процесс разработки модуля
weight: 1
---
**Цель:** Создать новую функцию для бота Quetza путем реализации самодостаточного модуля.
## 1. Фаза концептуализации
Перед написанием кода определите архитектуру модуля:
- **Простая команда:** Требуется только папка `commands/` (например, служебные команды).
- **Реактивная логика:** Нужна папка `events/` для прослушивания жизненного цикла Discord (например, приветственные сообщения).
- **Функция с состоянием:** Требуется **Контроллер** (Controller) для управления состоянием на разных серверах (например, музыкальный плеер).
## 2. Создание структуры
Создайте структуру директорий модуля. Наличие этих файлов запускает автоматическое обнаружение.
{{< filetree/container >}}
{{< filetree/folder name="src" state="open" >}}
{{< filetree/folder name="modules" state="open" >}}
{{< filetree/folder name="my-feature" state="open" >}}
{{< filetree/file name="module.ts" >}}
{{< filetree/folder name="commands" >}} {{< /filetree/folder >}}
{{< filetree/folder name="events" >}} {{< /filetree/folder >}}
{{< filetree/folder name="lib" >}} {{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< /filetree/container >}}
## 3. Шаги реализации
{{% steps %}}
### Шаг 1: Создание манифеста
Создайте `module.ts` для объявления модуля.
```typescript
// src/modules/my-feature/module.ts
export const name = 'my-feature';
export const description = 'Добавляет крутые возможности';
// Опционально: export const controller = new MyFeatureController();
```
### Шаг 2: Реализация команд
Создайте файлы команд в папке `commands/`. Каждый файл экспортирует данные и функцию-исполнитель (executor).
```typescript
// src/modules/my-feature/commands/ping.ts
import { SlashCommandBuilder } from 'discord.js';
export const data = new SlashCommandBuilder().setName('ping').setDescription('Отвечает Pong!');
export async function execute(interaction) {
await interaction.reply('Pong!');
}
```
### Шаг 3: Реализация событий (Опционально)
Создайте обработчики событий в `events/` для отслеживания состояния Discord.
```typescript
// src/modules/my-feature/events/ready.ts
import { Events } from 'discord.js';
export const name = Events.ClientReady;
export const once = true;
export function execute(client) {
console.log(`Ready! Logged in as ${client.user.tag}`);
}
```
### Шаг 4: Определение контроллера (Опционально)
Для логики, требующей сохранения состояния, реализуйте контроллер в папке `lib/` и экспортируйте его в `module.ts`.
### Шаг 5: Валидация
Перезапустите бота.
1. Проверьте логи на наличие "Loaded module: my-feature".
2. Убедитесь, что команда `/ping` появилась в Discord.
{{% /steps %}}
## Шаблоны интеграции
| Паттерн | Сценарий использования | Компоненты |
| :--- | :--- | :--- |
| **Простая команда** | Утилиты без состояния (Ping, Бросок кубиков) | Только `commands/` |
| **Сервис с состоянием** | Музыка, RPG, Экономика | `commands/` + `lib/Controller` |
| **Событийно-ориентированный** | Автомодерация, Логирование | Только `events/` |
| **Гибридный** | Сложные функции (ИИ-чат) | Все компоненты |
---
**См. также:**
- [Руководство Discord.js: Slash-команды](https://discordjs.guide/creating-your-bot/slash-commands.html)
@@ -0,0 +1,63 @@
---
title: Интерактивные голосовые функции
weight: 3
---
**Цель:** Реализовать сложные голосовые взаимодействия, которые учитывают границы серверов (гильдий) и управляют временными ресурсами.
## 1. Стратегия ограничения ресурсов
Голосовые функции **ОБЯЗАТЕЛЬНО** должны быть ограничены рамками Гильдии (Guild).
- **Паттерн:** Контроллер поддерживает `Map<GuildID, Player>`.
- **Поведение:**
- **Подключение (Join):** Создать Player для `Гильдии A`, если он еще не существует.
- **Отключение (Leave):** Уничтожить Player и освободить соединения.
- **Изоляция:** Команды в `Гильдии B` никогда не влияют на `Гильдию A`.
## 2. Оркестрация команд
Голосовые функции требуют набора команд, координируемых через Контроллер.
| Команда | Действие | Логика Контроллера |
| :--- | :--- | :--- |
| `/join` | Подключиться к каналу | `createPlayer(guildId, channelId)` |
| `/play` | Добавить трек в очередь | `getPlayer(guildId).enqueue(track)` |
| `/stop` | Отключиться | `destroyPlayer(guildId)` |
## 3. Сохранение состояния
Аудиосессии эфемерны.
- **Временное состояние:** Активный аудиопоток (Теряется при отключении).
- **Постоянное состояние:** Очередь / Плейлист (Сохраняется в Контроллере до перезапуска).
> [!TIP]
> **Устойчивость:** Разделяйте логику _Очереди_ и логику _Голосового соединения_. Это позволяет боту переподключаться без потери плейлиста пользователя.
## 4. Пример реализации
```typescript
// lib/MusicController.ts
class MusicController {
private players = new Map<string, AudioPlayer>();
getPlayer(guildId: string): AudioPlayer {
if (!this.players.has(guildId)) {
this.players.set(guildId, new AudioPlayer());
}
return this.players.get(guildId);
}
destroyPlayer(guildId: string) {
this.players.get(guildId)?.disconnect();
this.players.delete(guildId);
}
}
```
---
**См. также:**
- [Руководство Discord.js Voice](https://discordjs.guide/voice/)
@@ -0,0 +1,12 @@
---
title: Устранение неполадок
weight: 5
---
Устранение неполадок в **Quetza** фокусируется на диагностике структурных и концептуальных проблем. Из-за его природы, основанной на соглашениях (convention-based), большинство ошибок протекают бесшумно (отсутствующие файлы), а не с шумом (трассировка стека/stack traces).
## Основные категории проблем
{{% cards %}}
{{< card link="diagnostics" title="Руководство по диагностике" icon="exclamation" subtitle="Решения для обнаружения модулей, регистрации команд и утечек состояния." >}}
{{% /cards %}}
@@ -0,0 +1,73 @@
---
title: Диагностика
weight: 1
---
**Цель:** Определить и устранить распространенные ловушки при разработке Quetza.
## 1. Сбои при обнаружении модулей
**Симптом:** Бот запускается, но ваш модуль игнорируется.
**Причина:** Нарушение соглашений файловой системы.
{{% details title="Чек-лист: Структура директорий" closed="false" %}}
- [ ] Существует ли `src/modules/<name>/`?
- [ ] Экспортирует ли `module.ts` переменную `name`?
- [ ] Находятся ли команды в `commands/` (а не в `cmds/`)?
- [ ] Запустили ли вы `pnpm build`? (Quetza запускается из `dist/`).
{{% /details %}}
## 2. Проблемы с регистрацией команд
**Симптом:** Команда `/command` не появляется в Discord.
### Глобальная регистрация против регистрации в тестовой гильдии (Guild)
- **Глобальная:** Команды, отправленные в приложение глобально, могут обновляться до 1 часа и подчиняются более строгим ограничениям скорости (rate limits).
- **Тестовая гильдия:** Quetza автоматически отправляет команды в конкретную гильдию в режиме разработки для мгновенного обновления.
- **Требование:** Убедитесь, что `NODE_ENV=development` и `DEV_GUILD_ID` корректно установлены в вашем `.env`.
### Ограничения скорости (Rate Limits)
Частые обновления глобальных команд могут вызвать ограничения скорости со стороны Discord. Используйте тестовую гильдию для итеративной разработки.
## 3. Ошибки взаимодействия (Interaction Failures)
**Симптом:** «Приложение не отвечает».
**Причина:** Выполнение команды заняло более 3 секунд без отложенного ответа (deferral).
**Решение:**
```typescript
// ПЛОХО
await longRunningTask();
await interaction.reply('Готово'); // Слишком поздно!
// ХОРОШО
await interaction.deferReply();
await longRunningTask();
await interaction.editReply('Готово');
```
## 4. Утечки состояния
**Симптом:** Потребление памяти ботом растет; странности во взаимодействии между серверами.
**Причина:** Хранение состояния глобально вместо уровня гильдии.
**Диагностика:**
Проверьте ваш контроллер.
- ❌ `private player: AudioPlayer;` (Общий для всех!)
- ✅ `private players = new Map<string, AudioPlayer>();` (Разделен по ID гильдии).
## 5. Обработка событий
**Симптом:** События срабатывают несколько раз или не срабатывают вовсе.
- **Несколько раз:** События являются аддитивными. Не зарегистрировали ли вы один и тот же обработчик в двух модулях?
- **Ни разу:** Правильно ли вы экспортировали `name` и `execute`?
> [!TIP]
> **Debug Mode:**
> Включите подробное логирование, чтобы точно увидеть, какие модули и команды загружаются при запуске.
+26 -5
View File
@@ -1,12 +1,33 @@
---
title: 'root.ermnvldmr.com'
description: 'Документация к инфраструктуре root.ermnvldmr.com'
title: Documentation of ermnvldmr.com root deployment commons
description: Документация общих модулей корневой инфраструктуры ermnvldmr.com.
weight: 1
type: docs
cascade:
type: docs
---
Различные заметки, сделанные в процессе разработки этой инфраструктуры.
Фреймворк `ermnvldmr.com-root-commons` предоставляет среду развертывания с минимальными зависимостями для независимых self-hosted проектов.
Общая логика, скрипты и шаблоны развертывания, используемые в различных приложениях, хранятся в репозитории [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons).
## Основные возможности
- **Smart Dispatcher**: Единая точка входа (`root`) для всех задач оркестрации.
- **Tiered Storage**: Стандартизированная классификация для конфигураций, данных и логов.
- **Privilege Isolation**: Изоляция привилегий через выделенных non-root пользователей для каждого проекта.
- **Namespaced Libraries**: Явные модульные Bash-утилиты для обеспечения согласованной автоматизации.
## Разделы
- **[Рецепты](recipes/)** -- Пошаговые инструкции,
которые мне не хочется изобретать каждый раз снова.
{{% cards %}}
{{< card link="concepts" title="Концепции" icon="book-open" subtitle="Теоретические основы, архитектура и модели безопасности." >}}
{{< card link="guides" title="Руководства" icon="map" subtitle="Пошаговые инструкции по установке и разработке Action-скриптов." >}}
{{< card link="reference" title="Справочник" icon="code" subtitle="API и технические справочники для разработчиков." >}}
{{% /cards %}}
---
**See also:**
- [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons)
@@ -0,0 +1,14 @@
---
title: Концепции
description: Теоретические основы, архитектура и модели безопасности.
weight: 10
---
Теоретические основы, архитектура и модели безопасности общих модулей корневой инфраструктуры ermnvldmr.com.
{{% cards %}}
{{< card link="principles" title="Принципы инфраструктуры" icon="book-open" subtitle="Предсказуемость, минимум зависимостей и децентрализация." >}}
{{< card link="architecture" title="Архитектура инфраструктуры" icon="share" subtitle="Децентрализованная оркестрация и модели развертывания." >}}
{{< card link="security-model" title="Модель безопасности и изоляция" icon="shield-check" subtitle="Изоляция привилегий и защита сети." >}}
{{< card link="tiered-storage" title="Модель многоуровневого хранения" icon="database" subtitle="Стандартизированная классификация хранения данных сервисов." >}}
{{% /cards %}}
@@ -0,0 +1,71 @@
---
title: Архитектура инфраструктуры
description: Децентрализованная модель оркестрации и структура развертывания.
weight: 10
---
Фреймворк Root работает на децентрализованной модели оркестрации, разработанной для независимых, самодостаточных проектов. Он обеспечивает строгое разделение между оркестратором («мозгом») и задачами автоматизации («мышцами»).
## Основная философия {#philosophy}
Фреймворк построен на принципе **Smart Root, Stupid Actions** («Умный Root, глупые Action-скрипты»). Такой дизайн гарантирует, что отдельные скрипты автоматизации остаются декларативными и не знают о глобальном состоянии системы, в то время как центральный диспетчер берет на себя разрешение окружения и инициализацию библиотек.
### Smart Root (Диспетчер)
Точка входа `root` является единственным интерфейсом для пользователей. Она отвечает за:
- **Разрешение окружения** &rarr; Поиск репозиториев `commons` и `configs`.
- **Подготовка библиотек** &rarr; Разовая загрузка пространств имен (modules) библиотек.
- **Установление контекста** &rarr; Определение целевого [Узла (Node)](/root/glossary#node) и связанных с ним путей.
### Stupid Actions (Задачи)
[Action-скрипты (Actions)](/root/glossary#action) — это декларативные скрипты, расположенные в `scripts/actions/`. Они называются «глупыми», потому что:
- Они не вычисляют пути и не ищут репозитории.
- Они полностью зависят от переменных окружения `ROOT_`.
- Они фокусируются исключительно на одной атомарной задаче (например, `compose`, `ufw`).
## Модель развертывания {#deployment}
Они обычно развертываются как общий репозиторий [Commons](/root/glossary#commons), подключенный как Git-субмодуль внутри репозитория конфигураций. Движок, на котором построена эта общая инфраструктура, размещен по адресу [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)
-commons)
@@ -0,0 +1,33 @@
---
title: Принципы инфраструктуры
description: Основные принципы, обеспечивающие безопасную, предсказуемую среду с минимальными зависимостями.
weight: 5
---
Общие модули построены на ключевых принципах, предназначенных для обеспечения безопасной, предсказуемой среды с минимальными зависимостями для независимых self-hosted проектов.
## Предсказуемость через структуру {#predictability}
Ручная настройка сервера подвержена «дрейфу конфигурации» (configuration drift), когда хосты, которые должны быть идентичными, со временем начинают различаться. Общие модули решают эту проблему следующим образом:
- **Git как источник истины**: Каждая конфигурация, от правил UFW до crontab, хранится в репозитории.
- **Идемпотентная автоматизация**: [Action-скрипты](/root/glossary#action) разработаны так, чтобы их можно было запускать многократно без побочных эффектов, гарантируя, что хост всегда соответствует заданному состоянию.
## Минимальное количество зависимостей {#minimalism}
Для обеспечения долгосрочной поддерживаемости общие модули избегают сложных инструментов управления конфигурацией (таких как Ansible или Salt). Вместо этого они полагаются на:
- **Bash**: Универсальный язык Unix-систем.
- **Docker**: Для изоляции приложений и согласованности окружения.
- **Стандартные инструменты Linux**: Прямое использование `systemd`, `ufw` и `cron`.
## Децентрализация {#decentralization}
Каждый [Узел (Node)](/root/glossary#node) является независимым. Хотя они используют библиотеку общих модулей ([Commons](/root/glossary#commons)), они не зависят от центрального «мастер»-сервера. Такая архитектура гарантирует, что сбой на одном хосте не повлияет на несвязанные сервисы.
---
**См. также:**
- [Концепция: Модель безопасности и изоляция](/root/concepts/security-model)
- [Концепция: Модель многоуровневого хранения](/root/concepts/tiered-storage)
@@ -0,0 +1,64 @@
---
title: Безопасность и изоляция привилегий
description: Меры безопасности, изоляция привилегий и песочница на уровне проектов.
weight: 20
---
Безопасность в Root достигается за счет строгой изоляции привилегий и создания «песочниц» на уровне проектов для минимизации последствий в случае взлома отдельных сервисов.
## Принцип «No-Root» {#noroot}
Запуск приложений под пользователем `root` внутри контейнеров является значительным риском безопасности. Общие модули обеспечивают изоляцию с помощью действий `tiers/useradd` и `tiers/chown`.
### Пользователи для конкретных проектов
Каждому проекту, расположенному по пути `{repository root}/{node}/{project}`, назначается выделенный системный пользователь на хосте.
- **Изоляция**: Если контейнер будет взломан, злоумышленник получит только привилегии этого конкретного низкопривилегированного пользователя.
- **Безопасность файловой системы**: Проекты могут читать и записывать данные только в свои собственные директории в рамках [Уровней (Tiers)](/root/glossary#tier).
## Защита сокета Docker {#socket-protection}
Прямой доступ к `/var/run/docker.sock` эквивалентен правам root на хосте. В качестве лучшей практики настоятельно рекомендуется защищать сокет Docker. Один из способов достижения этого — использование **Socket Proxy** (например, на базе `tecnativa/docker-socket-proxy`) для фильтрации и ограничения доступа к 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:
# Использовать прокси вместо локального сокета
- "--providers.docker.endpoint=tcp://root-socket-proxy:2375"
networks:
socket: {}
```
## Мониторинг безопасности (CrowdSec) {#crowdsec}
Фреймворк интегрирует **CrowdSec** для поведенческого анализа и автоматического реагирования.
- **Обнаружение вторжений**: CrowdSec отслеживает логи [Traefik](https://traefik.io/) и SSH на предмет подозрительных паттернов (например, брутфорс-атаки, сканирование портов).
- **Автоматическая блокировка**: При обнаружении угрозы **CrowdSec Firewall Bouncer** автоматически обновляет правила [UFW](https://help.ubuntu.com/community/UFW), блокируя вредоносный IP на уровне хоста.
- **Обмен данными об угрозах**: CrowdSec делится анонимизированными данными об угрозах со своим сообществом, обеспечивая превентивную защиту от известных злоумышленников.
## Сетевое ужесточение {#networking}
Root использует `configure/ufw` для реализации политики «Запрещено по умолчанию» (Default Deny).
- **Внутренняя маршрутизация**: Сервисы взаимодействуют через выделенную сеть-мост `root-proxy`, которая часто настраивается как `internal: true`.
- **Изоляция анклавов**: Исходящий трафик может быть направлен через выделенный узел выхода (egress node) для детального контроля.
@@ -0,0 +1,71 @@
---
title: Модель многоуровневого хранения
description: Стандартизированная таксономия хранения данных сервисов.
weight: 15
---
Фреймворк навязывает стандартизированную таксономию хранения. Это гарантирует, что каждый сервис имеет предсказуемое местоположение для своих данных, что упрощает стратегии резервного копирования и распределение оборудования.
## Таксономия хранения {#taxonomy}
Данные разделены на три логических [Уровня (Tiers)](/root/glossary#tier) на основе их требований к производительности, жизненного цикла и важности.
### Уровень 1 (Tier 1): Конфигурации и Кэш
**Путь:** `${ROOT_NODE_DIR}/@tier1`
- **Содержимое**: Конфигурационные файлы, небольшие базы данных SQLite, SSL-сертификаты, кэш приложений.
- **Характеристики**: Чрезвычайно высокая ценность, объем от низкого до среднего, требует минимальных задержек (latency).
- **Оборудование**: Высокоскоростные SSD-накопители, обычно общие с операционной системой.
### Уровень 2 (Tier 2): Данные приложений
**Путь:** `${ROOT_NODE_DIR}/@tier2`
- **Содержимое**: Пользовательские загрузки, большие базы данных, постоянное состояние приложений.
- **Характеристики**: Высокая ценность, объем от среднего до высокого, требует высокой надежности.
- **Оборудование**: Надежные HDD-кластеры, RAID-массивы или выделенные хранилища.
### Уровень 3 (Tier 3): Временные файлы и объемные медиа
**Путь:** `${ROOT_NODE_DIR}/@tier3`
- **Содержимое**: Логи, временные файлы, объемные медиа (фильмы, музыка) и другие некритичные большие данные.
- **Характеристики**: Низкая ценность, очень высокий объем.
- **Оборудование**: HDD большой емкости, оптимизированные для непрерывной последовательной записи/чтения (например, WD Purple). Обратите внимание, что Tier 3 **слишком медленный для кэша**.
## Стратегия резервного копирования {#backup}
Общие модули не навязывают конкретную стратегию резервного копирования и не предоставляют реализацию по умолчанию, обеспечивая полную гибкость на уровне узла. Ожидается, что операторы сами реализуют логику резервного копирования.
### Пример: Резервное копирование с помощью Restic
Распространенной лучшей практикой является использование **Restic** для резервного копирования. Например, вы можете создать файл compose узла оператора для запуска 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
network_mode: "host"
entrypoint: [ "restic" ]
user: "0:0"
```
### Реализация стратегии
Операторы могут реализовать свою стратегию, определив локальный Action в `.operator/local/scripts/actions/sync/tiers`. Диспетчер `root` затем сможет выполнить этот скрипт для запуска резервного копирования, позволяя детально контролировать способ сохранения данных.
## Обоснование {#justification}
1. **Оптимизация оборудования**: Сопоставляя уровни с конкретным оборудованием (SSD для Tier 1, RAID для Tier 2, Purples для Tier 3), мы оптимизируем как производительность, так и стоимость.
2. **Выборочное резервное копирование**: Отделение временных логов и объемных медиа (Tier 3) от критических конфигураций (Tier 1) предотвращает трату дорогостоящего места в хранилище резервных копий.
3. **Архитектурная ясность**: Любой оператор может проинспектировать директорию [Узла (Node)](/root/glossary#node) и сразу понять требования к хранению и приоритет его содержимого.
+41
View File
@@ -0,0 +1,41 @@
---
title: Глоссарий
description: Терминология, используемая в рамках общих модулей корневой инфраструктуры.
weight: 100
---
Терминология, используемая в рамках общих модулей корневой инфраструктуры ermnvldmr.com.
## Action {#action}
**Action (Действие)** - Атомарная задача автоматизации, расположенная в `scripts/actions/`. Action-скрипты следуют философии "Stupid Action" («Глупое действие»), оставаясь декларативными и полагаясь на диспетчер `root` для настройки окружения.
## Commons {#commons}
**Commons (Общие модули)** - Общий репозиторий, содержащий общую логику, модульные библиотеки и стандартизированные действия автоматизации, используемые на всех узлах. Ссылка на [GitHub: ermnvldmr.com-root-commons](https://github.com/deytenit/ermnvldmr.com-root-commons).
## Dispatcher {#dispatcher}
**Dispatcher (Диспетчер)** - Скрипт `root`, который служит точкой входа. Он разрешает пути, подготавливает окружение и выполняет запрошенный [Action](/root/glossary#action).
## Node {#node}
**Node (Узел)** - Физический или виртуальный хост, управляемый общими модулями. Каждый узел имеет выделенную директорию в репозитории конфигураций, содержащую его сервисы и локальные настройки.
## Root {#root}
**Root** - Имя утилиты командной строки, а также сервис оркестрации верхнего уровня внутри [Узла (Node)](/root/glossary#node) (обычно обрабатывающий Traefik и AdGuard).
## Tier {#tier}
**Tier (Уровень)** - Стандартизированная классификация хранения данных сервисов.
- **Tier 1 (Уровень 1)**: Конфигурация и критическое состояние.
- **Tier 2 (Уровень 2)**: Данные приложений и базы данных.
- **Tier 3 (Уровень 3)**: Логи, кэши и объемные медиафайлы.
---
**См. также:**
- [Концепция: Архитектура инфраструктуры](/root/concepts/architecture)
- [Концепция: Модель многоуровневого хранения](/root/concepts/tiered-storage)
@@ -0,0 +1,13 @@
---
title: Руководства
description: Пошаговые инструкции по установке и разработке Action-скриптов.
weight: 20
---
Пошаговые инструкции по установке и разработке в рамках общих модулей корневой инфраструктуры ermnvldmr.com.
{{% cards %}}
{{< card link="first-node" title="Создание вашего первого узла" icon="map" subtitle="Практическое руководство по созданию репозитория конфигураций." >}}
{{< card link="installation" title="Установка" icon="arrow-down" subtitle="Первоначальная настройка общих модулей на новом хосте." >}}
{{< card link="action-development" title="Разработка Action-скриптов" icon="code" subtitle="Создание и управление атомарными задачами автоматизации." >}}
{{% /cards %}}
@@ -0,0 +1,67 @@
---
title: Разработка Action-скриптов
description: Разработка атомарных задач автоматизации в рамках общих модулей.
weight: 30
---
[Action-скрипты](/root/glossary#action) — это атомарные задачи автоматизации. Следуя философии "Stupid Action" («Глупое действие»), они должны оставаться декларативными и полагаться на библиотеку для выполнения сложных операций.
## Создание нового Action-скрипта {#create}
{{% steps %}}
### Шаг 1: Создание файла скрипта
Поместите ваш скрипт в `scripts/actions/{group}/{name}`. Не используйте расширения файлов.
### Шаг 2: Реализация метаданных
Каждый Action-скрипт должен предоставлять описание и список аргументов для системы справки (help system).
```bash filename="scripts/actions/utils/my-action"
action_metadata() {
cat <<EOF
description: "Краткое описание задачи."
args:
- NODE: "Имя узла"
- PARAM: "Пользовательский параметр"
EOF
}
```
### Шаг 3: Инициализация контекста
Используйте вспомогательную функцию `root_require_node` для настройки окружения. Это должно быть первым функциональным шагом в вашем скрипте.
```bash filename="scripts/actions/utils/my-action"
root_require_node "${1:-}"
shift
```
### Шаг 4: Использование модульных функций
Выполняйте операции, используя библиотечные функции `root_*`. Полагайтесь на переменные окружения `ROOT_` для всех путей.
```bash filename="scripts/actions/utils/my-action"
root_log_info "Обработка узла $ROOT_NODE"
root_sys_restart_service "my-service"
```
{{% /steps %}}
## Пространства имен библиотек {#libraries}
Action-скрипты имеют доступ к следующим модулям:
- `root_core_*`: Логирование и уведомления в Telegram.
- `root_init_*`: Декларативная инициализация сервисов и управление пакетами.
- `root_sys_*`: Управление systemd и файлами юнитов.
- `root_template_*`: Рендеринг на основе envsubst и внедрение конфигураций.
- `root_validate_*`: Проверка ввода, файлов и ОС.
---
**См. также:**
- [Справочник: Справочник ROOT_ API](/root/reference/root-api)
- [Концепция: Архитектура инфраструктуры](/root/concepts/architecture)
@@ -0,0 +1,190 @@
---
title: "Создание узла (First Node)"
description: Практическое руководство по созданию вашего первого узла и подключению общих модулей.
weight: 25
---
**Цель:** Создать репозиторий конфигураций для вашей собственной инфраструктуры, подключить общие модули в качестве подмодуля (submodule) и запустить первый базовый узел.
## 1. Создание репозитория конфигураций
Ваша инфраструктура должна управляться из единого Git-репозитория. Этот репозиторий будет содержать конфигурации для всех ваших серверов (узлов).
{{% steps %}}
### Шаг 1: Инициализация репозитория
Создайте новую директорию и инициализируйте Git.
```bash
mkdir my-infrastructure
cd my-infrastructure
git init
```
### Шаг 2: Добавление общих модулей как подмодуля
Общие модули (`ermnvldmr.com-root-commons`) должны располагаться по пути `.operator/shared`.
```bash
mkdir -p .operator
git submodule add https://github.com/deytenit/ermnvldmr.com-root-commons.git .operator/shared
```
### Шаг 3: Создание загрузочного скрипта (Bootstrap)
Создайте файл `init.sh` в корне вашего репозитория. Этот скрипт свяжет общие модули с вашим локальным окружением.
```bash filename="init.sh"
#!/usr/bin/env bash
# init.sh
# Загрузка общих утилит из подмодуля
source "$(git -C "$(dirname "$0")" rev-parse --show-toplevel)/.operator/shared/scripts/lib/common.sh"
SCRIPT_NAME="init"
navigate_to_repo_root
log_info "Настройка Git hooks..."
git config core.hooksPath .operator/shared/githooks
git config --global --add safe.directory "${REPO_ROOT}"
log_info "Настройка PATH для диспетчера root..."
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 "Готово. Выполните 'source ~/.bashrc' или перезайдите в терминал."
```
Сделайте его исполняемым:
```bash
chmod +x init.sh
```
{{% /steps %}}
## 2. Определение вашего первого узла
Узел (Node) — это папка в корне репозитория, которая представляет один физический или виртуальный сервер.
### Структура узла
Давайте создадим узел с именем `daedalus`. Внутри него мы определим конфигурацию оператора (например, для резервного копирования) и базовый сервис.
```bash
mkdir -p daedalus/.operator/configs/{cron,crowdsec,systemd,ufw/docker,ufw/host}
mkdir -p daedalus/root
```
### Базовые настройки (Опционально)
Вы можете добавить файлы конфигурации (например, правила UFW или crontab) в директорию `daedalus/.operator/configs/`. Например, простой crontab:
```bash filename="daedalus/.operator/configs/cron/crontab.template"
# Запуск резервного копирования каждую ночь в 3:00
0 3 * * * root sync tiers daedalus
```
## 3. Настройка сервисов (Docker Compose)
Фреймворк опирается на `docker-compose.yml`.
### Инфраструктурный уровень (Узел оператора)
Файл `daedalus/docker-compose.yml` запускает инфраструктурные утилиты, такие как `restic` для бекапов или VPN-агенты. Этот compose-файл имеет прямой доступ к файловой системе через монтирование директорий `@tierX`.
```yaml filename="daedalus/docker-compose.yml"
name: 'operator'
services:
restic:
image: restic/restic:0.18.1
container_name: operator-restic
volumes:
# Пример: монтирование данных для резервного копирования (Только для чтения)
- ./@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...
network_mode: "host"
entrypoint: [ "restic" ]
```
### Корневой уровень (Root)
Проект `root` обычно содержит обратные прокси (Traefik) и сервисы, к которым обращаются другие проекты.
```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 и GID подставляются автоматически фреймворком после команды '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. Развертывание
Теперь, когда структура создана:
1. Закоммитьте и отправьте (push) этот репозиторий на ваш Git-сервер (например, GitHub).
2. Зайдите на ваш VPS.
3. Следуйте руководству по [Установке (Installation)](/root/guides/installation), используя URL вашего нового репозитория.
---
**См. также:**
- [Руководство: Установка на новый хост](/root/guides/installation)
- [Концепция: Архитектура инфраструктуры](/root/concepts/architecture)
@@ -41,10 +41,10 @@ groupadd wheel
# Создать модульный файл sudoers
# Это позволяет членам группы 'wheel' запускать sudo без пароля
echo "%wheel ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/99-root-ermnvldmr
echo "%wheel ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/99-custom-sudoers
# Проверить синтаксис
visudo -c -f /etc/sudoers.d/99-root-ermnvldmr
visudo -c -f /etc/sudoers.d/99-custom-sudoers
# Добавить adam в необходимые группы
usermod -aG wheel,docker adam
@@ -92,10 +92,10 @@ chown -R adam:adam /home/adam/.ssh
```bash
# ~/.ssh/config на стороне оператора
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
```
**Усиление демона SSH**
@@ -105,7 +105,7 @@ Host daedalus.root.ermnvldmr.com
```bash
# От имени root:
# Создать выделенный файл конфигурации
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]
> Добавьте вывод выше в [GitHub: Repository Deploy Keys](https://github.com/deytenit/root.ermnvldmr.com/settings/keys) с правами **read access**.
> Добавьте вывод выше в настройки Deploy Keys вашего репозитория инфраструктуры (например, `https://github.com/your-username/your-repo/settings/keys`) с правами **read access**.
### Развертывание репозитория
Клонируйте инфраструктурный репозиторий и инициализируйте узел.
Клонируйте репозиторий вашей инфраструктуры и инициализируйте узел.
```bash
# От имени root:
# Настроить директорию сервиса
export REPO_DIR="/srv/root.ermnvldmr.com"
# Настройка директории сервиса
export REPO_DIR="/srv/your-infrastructure-repo"
mkdir -p $REPO_DIR
chown adam:adam $REPO_DIR
# Клонировать от имени пользователя adam
sudo -u adam git clone git@github.com:deytenit/root.ermnvldmr.com.git $REPO_DIR
# Клонирование от имени пользователя adam
sudo -u adam git clone git@github.com:your-username/your-repo.git $REPO_DIR
# Инициализировать узел (Установите имя вашего узла, например, daedalus)
export NODE="daedalus"
@@ -161,16 +161,15 @@ sudo -u adam ./init.sh
**Специфичная для узла конфигурация**
Запустите скрипты инициализации по порядку:
Запустите скрипты инициализации с помощью диспетчера `root`:
```bash
# От имени root:
# Выполните последовательность для вашего конкретного узла
cd $REPO_DIR
./$NODE/.host/.scripts/00-base
./$NODE/.host/.scripts/10-ufw
./$NODE/.host/.scripts/20-crowd
./$NODE/.host/.scripts/30-cron
# Выполните последовательность для вашего конкретного узла (от имени пользователя 'adam')
# Убедитесь, что вы выполнили 'source ~/.bashrc' или переподключились, если команда 'root' не найдена
root configure base $NODE
root configure ufw $NODE
root configure crowdsec $NODE
root configure cron $NODE
```
### Хранение и усиление безопасности
@@ -178,18 +177,17 @@ cd $REPO_DIR
Определите уровни данных (tiers) и защитите среду, создав непривилегированных пользователей для контейнеризированных сервисов.
```bash
# От имени root:
# Создать (или примонтировать) директории хранилища
mkdir -p /srv/com-ermnvldmr-root-$NODE-{tier1,tier2,tier3}
sudo mkdir -p /srv/my-infrastructure-root-$NODE-{tier1,tier2,tier3}
# Настроить уровни и непривилегированных пользователей
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
**См. также:**
- [Doc: Docker - Install on Debian](https://docs.docker.com/engine/install/debian/)
- [GitHub: root.ermnvldmr.com](https://github.com/deytenit/root.ermnvldmr.com)
@@ -1,11 +0,0 @@
---
title: 'Рецепты'
description: 'Пошаговые инструкции, которые мне не хочется изобретать каждый раз снова.'
weight: 50
type: docs
---
Пошаговые инструкции,
которые мне не хочется изобретать каждый раз снова.
## Что здесь
@@ -0,0 +1,18 @@
---
title: Справочник
description: Справочные материалы и API для разработчиков.
weight: 30
---
Технические справочные материалы и API общих модулей корневой инфраструктуры ermnvldmr.com.
{{% cards %}}
{{< card link="compose" title="Справочник по действиям compose" icon="play" subtitle="Высокоуровневая точка входа для оркестрации docker-compose." >}}
{{< card link="configure" title="Справочник по действиям настройки" icon="arrow-down" subtitle="Действия для настройки системного уровня." >}}
{{< card link="initialization" title="Справочник по действиям инициализации" icon="play" subtitle="Действия для первоначальной настройки узла." >}}
{{< card link="root-api" title="Справочник ROOT_ API" icon="code" subtitle="Стандартизированные переменные окружения и вспомогательные функции логирования." >}}
{{< card link="source" title="Справочник по исходным сервисам" icon="book-open" subtitle="Общие сервисы, собираемые из исходного кода." >}}
{{< card link="sync" title="Справочник по действиям синхронизации" icon="check" subtitle="Действия для поддержания узлов в актуальном состоянии." >}}
{{< card link="tiers" title="Справочник по действиям уровней (Tiers)" icon="archive" subtitle="Управление уровнями хранения данных." >}}
{{< card link="utils" title="Справочник по утилитам" icon="check" subtitle="Вспомогательные скрипты и утилиты." >}}
{{% /cards %}}
@@ -0,0 +1,50 @@
---
title: Операции Compose
description: Стандартизированная точка входа для оркестрации docker-compose в масштабах узла.
weight: 54
---
Действие `compose` предоставляет стандартизированную высокоуровневую точку входа для оркестрации файлов Docker Compose для всех проектов в рамках определенного узла.
## Основная операция Compose {#actions}
| Операция | Описание |
| :--- | :--- |
| `compose <node> <action>` | Выполняет заданную команду Docker Compose (например, `up`, `down`, `restart`) по всей инфраструктуре узла. |
## Порядок оркестрации {#order}
Для поддержания правильных зависимостей (особенно при завершении работы или запуске прокси-серверов и сетей уровня инфраструктуры), действие `compose` интеллектуально упорядочивает выполнение:
### Запуск (`up`, `restart`)
При запуске или перезапуске сервисов действие использует **Прямой порядок** (Forward Order):
1. **Инфраструктура узла (Node Infra)**: Сначала обрабатывается файл `docker-compose.yml` уровня узла (находится в корне директории узла).
2. **Корневая инфраструктура (Root Infra)**: Затем обрабатывается проект `root/docker-compose.yml`, если он существует (обычно содержит Traefik, Socket Proxy и т.д.).
3. **Проекты**: Наконец, в алфавитном порядке обрабатываются все остальные директории проектов.
### Остановка (`down`)
При остановке сервисов действие использует **Обратный порядок** (Reverse Order), чтобы проекты корректно отключились до того, как упадет сеть или прокси:
1. **Проекты**: Все стандартные проекты останавливаются в обратном алфавитном порядке.
2. **Корневая инфраструктура (Root Infra)**: Проект `root/docker-compose.yml` останавливается.
3. **Инфраструктура узла (Node Infra)**: В самом конце отключается `docker-compose.yml` уровня узла.
## Опции {#options}
Вы можете передавать стандартные аргументы Docker Compose в конец команды.
Кроме того, действие поддерживает режим пробного запуска (dry-run):
- `--dry-run`: Выводит команды, которые будут выполнены, без их фактического запуска.
**Пример использования:**
```bash
root compose daedalus up -d --remove-orphans
root compose daedalus restart --dry-run
root compose daedalus down
```
---
**See also:**
- [Концепция: Архитектура инфраструктуры](/root/concepts/architecture)
- [Справочник: Справочник ROOT_ API Reference](/root/reference/root-api)
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more