Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a20e34a37c | ||
|
|
208f6406e6
|
||
|
|
82236be7d6
|
||
|
|
7ac7c1b405
|
||
|
|
77fa576ac6
|
||
|
|
07b16d404c | ||
|
|
a103313480 | ||
|
|
51a783f93a | ||
|
|
8dba55272c | ||
|
|
6b0a893c86 | ||
|
|
575959ffa2 |
No files matched your search
@@ -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:**
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
"default": "./src/testing.ts"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rsbuild build",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 */
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+20
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
+9
-3
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+1409
-489
File diff suppressed because it is too large.
Load diff
@@ -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 >}}
|
||||
+3
-2
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
+3
-2
@@ -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.
|
||||
@@ -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** → Locating the `commons` and `configs` repositories.
|
||||
- **Library warming** → Sourcing namespaced modules exactly once.
|
||||
- **Context establishment** → 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)
|
||||
@@ -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)
|
||||
+25
-28
@@ -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)
|
||||
@@ -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 >}}
|
||||
@@ -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:**
|
||||
> Включите подробное логирование, чтобы точно увидеть, какие модули и команды загружаются при запуске.
|
||||
@@ -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` является единственным интерфейсом для пользователей. Она отвечает за:
|
||||
|
||||
- **Разрешение окружения** → Поиск репозиториев `commons` и `configs`.
|
||||
- **Подготовка библиотек** → Разовая загрузка пространств имен (modules) библиотек.
|
||||
- **Установление контекста** → Определение целевого [Узла (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) и сразу понять требования к хранению и приоритет его содержимого.
|
||||
@@ -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)
|
||||
+25
-28
@@ -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
Reference in new issue
Block a user