Add joplin web article (#9)
This commit is contained in:
1 parent
90bf68c4c8
commit
575959ffa2
37 files changed
+1019
-530
No files matched your search
@@ -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'],
|
||||
|
||||
|
||||
+3
-1
@@ -43,7 +43,9 @@
|
||||
"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",
|
||||
"postinstall": "pnpm build:deps"
|
||||
},
|
||||
"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'));
|
||||
});
|
||||
@@ -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,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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>> = {
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('PageHead', () => {
|
||||
it('applies the correct classes for the strategy', () => {
|
||||
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header).toHaveClass('fixed');
|
||||
expect(header).toHaveClass('sticky');
|
||||
expect(header).toHaveClass('shadow-md');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -68,8 +68,6 @@ export const PageHead = ({
|
||||
}: PageHeadProps) => {
|
||||
const { y: scrollY } = useScroll();
|
||||
const [isCollapsed, setIsCollapsed] = useState(strategy.includes('always-collapsed'));
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const { height } = useResizeObserver(headerRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (strategy === 'collapsible-sticky') {
|
||||
@@ -78,7 +76,7 @@ export const PageHead = ({
|
||||
}, [scrollY, strategy]);
|
||||
|
||||
const rootClasses = cn(
|
||||
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] fixed top-0 z-10 border-b border-[var(--rb-outline)]/10',
|
||||
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] sticky top-0 z-10 border-b border-[var(--rb-outline)]/10',
|
||||
{
|
||||
'shadow-md bg-[var(--rb-background)]/90 backdrop-blur-md': isCollapsed,
|
||||
},
|
||||
@@ -124,8 +122,7 @@ export const PageHead = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header ref={headerRef} className={rootClasses}>
|
||||
<header className={rootClasses}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] duration-300 ease-in-out',
|
||||
@@ -145,7 +142,5 @@ export const PageHead = ({
|
||||
<div className="overflow-hidden">{collapsedContent}</div>
|
||||
</div>
|
||||
</header>
|
||||
<div style={{ height }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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'
|
||||
|
||||
+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
+2
-96
@@ -33,9 +33,6 @@ importers:
|
||||
eslint-config-prettier:
|
||||
specifier: ^10.1.2
|
||||
version: 10.1.8(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-astro:
|
||||
specifier: ^1.3.1
|
||||
version: 1.5.0(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import:
|
||||
specifier: ^2.32.0
|
||||
version: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))
|
||||
@@ -231,7 +228,7 @@ importers:
|
||||
version: 7.0.1
|
||||
storybook-react-rsbuild:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(@rsbuild/core@1.7.3)(@storybook/test@8.6.15(storybook@8.6.17(prettier@3.8.1)))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.56.0)(storybook@8.6.17(prettier@3.8.1))(typescript@5.8.3)(webpack@5.105.1)
|
||||
version: 1.0.3(@rsbuild/core@1.7.3)(@storybook/test@8.6.15(storybook@8.6.17(prettier@3.8.1)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.56.0)(storybook@8.6.17(prettier@3.8.1))(typescript@5.8.3)(webpack@5.105.1)
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.8.3
|
||||
@@ -672,9 +669,6 @@ packages:
|
||||
resolution: {integrity: sha512-Hb4o6h1Pf6yRUAX07DR4JVY7dmQw+RVQMW5/m55GoiAT/VRoKCWBtIUPPOnqDVhbx1Cjfil9b6EDrgJsUAujEQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@astrojs/compiler@2.13.0':
|
||||
resolution: {integrity: sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==}
|
||||
|
||||
'@astrojs/compiler@2.13.1':
|
||||
resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==}
|
||||
|
||||
@@ -1749,10 +1743,6 @@ packages:
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@pkgr/core@0.2.9':
|
||||
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
@@ -4209,16 +4199,6 @@ packages:
|
||||
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
|
||||
hasBin: true
|
||||
|
||||
astro-eslint-parser@1.2.2:
|
||||
resolution: {integrity: sha512-JepyLROIad6f44uyqMF6HKE2QbunNzp3mYKRcPoDGt0QkxXmH222FAFC64WTyQu2Kg8NNEXHTN/sWuUId9sSxw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
astrojs-compiler-sync@1.1.1:
|
||||
resolution: {integrity: sha512-0mKvB9sDQRIZPsEJadw6OaFbGJ92cJPPR++ICca9XEyiUAZqgVuk25jNmzHPT0KF80rI94trSZrUR5iHFXGGOQ==}
|
||||
engines: {node: ^18.18.0 || >=20.9.0}
|
||||
peerDependencies:
|
||||
'@astrojs/compiler': '>=0.27.0'
|
||||
|
||||
async-function@1.0.0:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4923,12 +4903,6 @@ packages:
|
||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
eslint-compat-utils@0.6.5:
|
||||
resolution: {integrity: sha512-vAUHYzue4YAa2hNACjB8HvUQj5yehAZgiClyFVVom9cP8z5NSFq3PwB/TtJslN2zAMgRX6FCFCjYBbQh71g5RQ==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
eslint: '>=6.0.0'
|
||||
|
||||
eslint-config-prettier@10.1.8:
|
||||
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
|
||||
hasBin: true
|
||||
@@ -4959,12 +4933,6 @@ packages:
|
||||
eslint-import-resolver-webpack:
|
||||
optional: true
|
||||
|
||||
eslint-plugin-astro@1.5.0:
|
||||
resolution: {integrity: sha512-IWy4kY3DKTJxd7g652zIWpBGFuxw7NIIt16kyqc8BlhnIKvI8yGJj+Maua0DiNYED3F/D8AmzoTTTA6A95WX9g==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: '>=8.57.0'
|
||||
|
||||
eslint-plugin-import@2.32.0:
|
||||
resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -6664,10 +6632,6 @@ packages:
|
||||
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -7389,10 +7353,6 @@ packages:
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
synckit@0.11.12:
|
||||
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
||||
tailwind-merge@3.4.0:
|
||||
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
||||
|
||||
@@ -8101,8 +8061,6 @@ snapshots:
|
||||
'@ast-grep/napi-win32-ia32-msvc': 0.37.0
|
||||
'@ast-grep/napi-win32-x64-msvc': 0.37.0
|
||||
|
||||
'@astrojs/compiler@2.13.0': {}
|
||||
|
||||
'@astrojs/compiler@2.13.1':
|
||||
optional: true
|
||||
|
||||
@@ -9291,8 +9249,6 @@ snapshots:
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@pkgr/core@0.2.9': {}
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
@@ -12819,28 +12775,6 @@ snapshots:
|
||||
|
||||
astring@1.9.0: {}
|
||||
|
||||
astro-eslint-parser@1.2.2:
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.0
|
||||
'@typescript-eslint/scope-manager': 8.53.1
|
||||
'@typescript-eslint/types': 8.53.1
|
||||
astrojs-compiler-sync: 1.1.1(@astrojs/compiler@2.13.0)
|
||||
debug: 4.4.3
|
||||
entities: 6.0.1
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
fast-glob: 3.3.3
|
||||
is-glob: 4.0.3
|
||||
semver: 7.7.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
astrojs-compiler-sync@1.1.1(@astrojs/compiler@2.13.0):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.0
|
||||
synckit: 0.11.12
|
||||
|
||||
async-function@1.0.0: {}
|
||||
|
||||
async@3.2.6: {}
|
||||
@@ -13668,11 +13602,6 @@ snapshots:
|
||||
|
||||
escape-string-regexp@5.0.0: {}
|
||||
|
||||
eslint-compat-utils@0.6.5(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
semver: 7.7.3
|
||||
|
||||
eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
@@ -13695,20 +13624,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-astro@1.5.0(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@typescript-eslint/types': 8.53.1
|
||||
astro-eslint-parser: 1.2.2
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-compat-utils: 0.6.5(eslint@9.39.2(jiti@2.6.1))
|
||||
globals: 16.5.0
|
||||
postcss: 8.5.6
|
||||
postcss-selector-parser: 7.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
@@ -16023,11 +15938,6 @@ snapshots:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss@8.5.6:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
@@ -16840,7 +16750,7 @@ snapshots:
|
||||
- tslib
|
||||
- webpack
|
||||
|
||||
storybook-react-rsbuild@1.0.3(@rsbuild/core@1.7.3)(@storybook/test@8.6.15(storybook@8.6.17(prettier@3.8.1)))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.56.0)(storybook@8.6.17(prettier@3.8.1))(typescript@5.8.3)(webpack@5.105.1):
|
||||
storybook-react-rsbuild@1.0.3(@rsbuild/core@1.7.3)(@storybook/test@8.6.15(storybook@8.6.17(prettier@3.8.1)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.56.0)(storybook@8.6.17(prettier@3.8.1))(typescript@5.8.3)(webpack@5.105.1):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.56.0)
|
||||
'@rsbuild/core': 1.7.3
|
||||
@@ -17023,10 +16933,6 @@ snapshots:
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
synckit@0.11.12:
|
||||
dependencies:
|
||||
'@pkgr/core': 0.2.9
|
||||
|
||||
tailwind-merge@3.4.0: {}
|
||||
|
||||
tailwindcss@4.1.18: {}
|
||||
|
||||
Vendored
-1
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Type definitions for the static service
|
||||
*/
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
|
||||
interface PerformanceNavigationTiming {
|
||||
responseStatus?: number;
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"@ermnvldmr/ui/*": ["../../../packages/ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": [".astro/types.d.ts", "src/**/*"],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "**/*.config.*"]
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: 'Joplin Web Deployment Guide'
|
||||
description: 'Joplin Server & Web App deployment from scratch'
|
||||
createdDate: 2026-03-20
|
||||
tags: ['joplin', 'deployment', 'guide']
|
||||
---
|
||||
|
||||
This guide describes how to deploy a private note-taking infrastructure using the Joplin Server and its web client.
|
||||
|
||||
## Why I Chose Joplin?
|
||||
|
||||
I have been using Joplin for a long time, and it has become my primary note-taking tool. There are several reasons why I chose it over other alternatives:
|
||||
|
||||
- **Local-First**: Notes are stored locally on your device, ensuring fast access and full ownership (especially when the internet is inaccessible).
|
||||
- **Markdown Format**: Joplin uses Markdown for all notes, making it incredibly easy to export, import, or move your data to another platform if needed.
|
||||
- **Easy Sharing**: The ability to share notes via the web makes collaboration or public sharing straightforward.
|
||||
- **Backups**: Joplin provides robust backup options, giving me peace of mind that my data is safe.
|
||||
|
||||
One thing that was missing for me was the ability to quickly add or view notes when I don't have access to my primary devices (for example, at work). This article provides the solution: a self-hosted web client.
|
||||
|
||||
## Technical Stack Overview
|
||||
|
||||
1. **Joplin Server**: The synchronization server. It handles the Joplin sync protocol, account logic, and note synchronization across devices.
|
||||
2. **Postgres**: The database. Essential for production performance.
|
||||
3. **Joplin Web App**: A browser-based client built from the Joplin mobile app source code using React Native for Web.
|
||||
|
||||
## 1. The Deployment Stack
|
||||
|
||||
This configuration is generic, uses **Docker Volumes**, and provides a minimal setup for deploying the full stack.
|
||||
|
||||
### Main docker-compose.yml
|
||||
|
||||
This compose file is a minimal configuration to run the Joplin Server and the web app. It assumes you understand how to set up a reverse proxy, or better yet, already have your own Joplin setup.
|
||||
|
||||
```yaml
|
||||
x-environment: &shared-environment
|
||||
TZ: 'Europe/Moscow'
|
||||
POSTGRES_DB: 'joplin'
|
||||
POSTGRES_USER: ${SHARED_USERNAME?:error}
|
||||
POSTGRES_PASSWORD: ${SHARED_PASSWORD?:error}
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: joplin-postgres
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
<<: *shared-environment
|
||||
restart: unless-stopped
|
||||
|
||||
server:
|
||||
image: joplin/server:3.5.2
|
||||
container_name: joplin-server
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
<<: *shared-environment
|
||||
APP_PORT: 22300
|
||||
APP_BASE_URL: 'https://example.com'
|
||||
DB_CLIENT: 'pg'
|
||||
POSTGRES_HOST: 'joplin-postgres'
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DATABASE: 'joplin'
|
||||
ports:
|
||||
- '22300:22300'
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ./app
|
||||
args:
|
||||
- GIT_TAG=v3.5.13
|
||||
container_name: joplin-app
|
||||
environment:
|
||||
<<: *shared-environment
|
||||
ports:
|
||||
- '8080:8080'
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
### Dockerfile
|
||||
|
||||
Create an `app` directory and place this `Dockerfile` inside it to build the web client. We build our own image since the original authors don't provide one.
|
||||
|
||||
While there are repositories with pre-built images, it's easy enough to just create our own.
|
||||
|
||||
```dockerfile
|
||||
# Stage 1: Build
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
|
||||
ARG GIT_TAG=v3.5.13
|
||||
|
||||
# Install build dependencies for Joplin monorepo
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git python3 build-essential rsync \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Configure regional mirror and timeout for reliability
|
||||
RUN corepack enable && \
|
||||
yarn config set registry https://registry.npmmirror.com
|
||||
|
||||
# Clone specific tag
|
||||
RUN git clone --depth 1 --branch ${GIT_TAG} https://github.com/laurent22/joplin.git .
|
||||
|
||||
# Install dependencies and build the web distribution
|
||||
RUN yarn install --network-timeout 1000000
|
||||
RUN cd packages/app-mobile/ && yarn web
|
||||
|
||||
# Stage 2: Production Server
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default assets
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy built app and custom nginx config
|
||||
COPY --from=builder /app/packages/app-mobile/web/dist /usr/share/nginx/html/app
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
### Nginx configuration
|
||||
|
||||
Place this `nginx.conf` alongside the `Dockerfile` in the `app` directory. Joplin is an SPA, meaning it doesn't matter where it's deployed since all navigation is handled internally without location dependency. So it's alright to just deploy it on a path that doesn't overlap with the main server, such as `/app`.
|
||||
|
||||
> This is a pretty minimal configuration; it assumes your reverse proxy is good enough to cover all questionable items, but you can expand it as you like.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression for performance
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_min_length 1000;
|
||||
|
||||
# SPA Routing: Fall back to index.html if a file isn't found
|
||||
location /app/ {
|
||||
try_files $uri $uri/ /app/index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|webmanifest)$ {
|
||||
expires 6M;
|
||||
access_log off;
|
||||
add_header Cache-Control "public, max-age=15552000, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Configuration
|
||||
|
||||
Create a `.env` file in the same directory as your `docker-compose.yml`. **Do not skip the secret generation step below.**
|
||||
|
||||
```bash
|
||||
# --- Database Credentials ---
|
||||
SHARED_USERNAME=joplin
|
||||
SHARED_PASSWORD=generate_this_with_openssl
|
||||
```
|
||||
|
||||
### Generating Secrets
|
||||
|
||||
Use `openssl` to easily generate secure, random strings for your `.env` entries:
|
||||
|
||||
```bash
|
||||
# Generate SHARED_PASSWORD
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## 3. Deployment
|
||||
|
||||
Start your stack by running the following command:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## 4. Initial Server Setup
|
||||
|
||||
Before configuring the web app, you must initialize your Joplin Server:
|
||||
|
||||
Follow the official guide: [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
|
||||
|
||||
Essentially, you need to ensure that basic synchronization via the app is functional.
|
||||
|
||||
## 5. Web App Showcase & Setup
|
||||
|
||||
Once the web app is running at `https://example.com/app/`, you will be greeted by it.
|
||||
|
||||
### About the Web App
|
||||
|
||||

|
||||
|
||||
It is basically the same mobile Joplin app, but in-browser. It leverages the LocalStorage API for data handling. Everything is still local-first, so after logging in, it will take some time to synchronize all remote notes.
|
||||
|
||||
But most importantly: _The Joplin web interface allows you to view and edit your notes directly from the browser._
|
||||
|
||||
> It is said that it's only guaranteed to work on Chrome due to the required API compatibility; however, I have had great success with Firefox as well. Browsers are catching up quickly.
|
||||
|
||||
### Authentication Setup
|
||||
|
||||
To connect to your self-hosted Joplin Server, go to **Settings > Synchronization** and configure the following parameters:
|
||||
|
||||
- **Sync Target**: Joplin Server
|
||||
- **Joplin Server URL**: `https://example.com`
|
||||
- **Joplin Server Email**: `your-email@example.com` (the one you just created/configured)
|
||||
- **Joplin Server Password**: `your-password`
|
||||
|
||||

|
||||
|
||||
_Enjoy!_
|
||||
|
||||
## Resources
|
||||
|
||||
- [Joplin GitHub](https://github.com/laurent22/joplin)
|
||||
- [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: 'Руководство по развертыванию Joplin Web'
|
||||
description: 'Развертывание Joplin Server и веб-приложения с нуля'
|
||||
createdDate: 2026-03-20
|
||||
tags: ['joplin', 'deployment', 'guide']
|
||||
---
|
||||
|
||||
Это руководство описывает, как развернуть приватную инфраструктуру для заметок, используя Joplin Server и его веб-клиент.
|
||||
|
||||
## Почему я выбрал Joplin?
|
||||
|
||||
Я давно пользуюсь Joplin, и он стал моим основным инструментом для ведения заметок. Есть несколько причин, по которым я выбрал его среди других альтернатив:
|
||||
|
||||
- **Local-First**: Заметки хранятся локально на вашем устройстве, что обеспечивает быстрый доступ и полное владение данными (особенно когда интернет недоступен).
|
||||
- **Формат Markdown**: Joplin использует Markdown для всех заметок, что позволяет невероятно легко экспортировать, импортировать или переносить данные на другую платформу при необходимости.
|
||||
- **Удобный обмен**: Возможность делиться заметками через веб делает совместную работу или публичный доступ простыми.
|
||||
- **Резервное копирование**: Joplin предоставляет надежные варианты резервного копирования, давая мне уверенность в сохранности моих данных.
|
||||
|
||||
Одной вещи мне не хватало — возможности быстро добавить или просмотреть заметки, когда у меня нет доступа к моим основным устройствам (например, на работе). Эта статья предлагает решение: самостоятельно размещенный (self-hosted) веб-клиент.
|
||||
|
||||
## Обзор технологического стека
|
||||
|
||||
1. **Joplin Server**: Сервер синхронизации. Он обрабатывает протокол синхронизации Joplin, логику учетных записей и синхронизацию заметок между устройствами.
|
||||
2. **Postgres**: База данных. Необходима для производительности в рабочей среде.
|
||||
3. **Joplin Web App**: Браузерный клиент, созданный из исходного кода мобильного приложения Joplin с использованием React Native for Web.
|
||||
|
||||
## 1. Стек развертывания
|
||||
|
||||
Эта конфигурация является универсальной, использует **Docker Volumes** и предоставляет минимальную настройку для развертывания полного стека.
|
||||
|
||||
### Основной docker-compose.yml
|
||||
|
||||
Этот compose-файл представляет собой минимальную конфигурацию для запуска Joplin Server и веб-приложения. Предполагается, что вы понимаете, как настроить обратный прокси-сервер, или, что еще лучше, у вас уже есть собственная установка Joplin.
|
||||
|
||||
```yaml
|
||||
x-environment: &shared-environment
|
||||
TZ: 'Europe/Moscow'
|
||||
POSTGRES_DB: 'joplin'
|
||||
POSTGRES_USER: ${SHARED_USERNAME?:error}
|
||||
POSTGRES_PASSWORD: ${SHARED_PASSWORD?:error}
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: joplin-postgres
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
<<: *shared-environment
|
||||
restart: unless-stopped
|
||||
|
||||
server:
|
||||
image: joplin/server:3.5.2
|
||||
container_name: joplin-server
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
<<: *shared-environment
|
||||
APP_PORT: 22300
|
||||
APP_BASE_URL: 'https://example.com'
|
||||
DB_CLIENT: 'pg'
|
||||
POSTGRES_HOST: 'joplin-postgres'
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DATABASE: 'joplin'
|
||||
ports:
|
||||
- '22300:22300'
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ./app
|
||||
args:
|
||||
- GIT_TAG=v3.5.13
|
||||
container_name: joplin-app
|
||||
environment:
|
||||
<<: *shared-environment
|
||||
ports:
|
||||
- '8080:8080'
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
### Dockerfile
|
||||
|
||||
Создайте директорию `app` и поместите в нее этот `Dockerfile` для сборки веб-клиента. Мы собираем собственный образ, так как авторы оригинала его не предоставляют.
|
||||
|
||||
Хотя существуют репозитории с готовыми образами, создать свой достаточно просто.
|
||||
|
||||
```dockerfile
|
||||
# Stage 1: Build
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
|
||||
ARG GIT_TAG=v3.5.13
|
||||
|
||||
# Install build dependencies for Joplin monorepo
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git python3 build-essential rsync \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Configure regional mirror and timeout for reliability
|
||||
RUN corepack enable && \
|
||||
yarn config set registry https://registry.npmmirror.com
|
||||
|
||||
# Clone specific tag
|
||||
RUN git clone --depth 1 --branch ${GIT_TAG} https://github.com/laurent22/joplin.git .
|
||||
|
||||
# Install dependencies and build the web distribution
|
||||
RUN yarn install --network-timeout 1000000
|
||||
RUN cd packages/app-mobile/ && yarn web
|
||||
|
||||
# Stage 2: Production Server
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default assets
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy built app and custom nginx config
|
||||
COPY --from=builder /app/packages/app-mobile/web/dist /usr/share/nginx/html/app
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
### Конфигурация Nginx
|
||||
|
||||
Поместите этот `nginx.conf` рядом с `Dockerfile` в директории `app`. Joplin — это SPA (Single Page Application), а значит, не имеет значения, где оно развернуто, так как вся навигация обрабатывается внутренне без зависимости от местоположения. Поэтому вполне нормально развернуть его по пути, который не пересекается с основным сервером, например `/app`.
|
||||
|
||||
> Это довольно минималистичная конфигурация; предполагается, что ваш обратный прокси достаточно хорош, чтобы закрыть все спорные моменты, но вы можете расширить ее по своему усмотрению.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip-сжатие для производительности
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_min_length 1000;
|
||||
|
||||
# SPA-роутинг: возврат к index.html, если файл не найден
|
||||
location /app/ {
|
||||
try_files $uri $uri/ /app/index.html;
|
||||
}
|
||||
|
||||
# Кэширование статических ресурсов
|
||||
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|webmanifest)$ {
|
||||
expires 6M;
|
||||
access_log off;
|
||||
add_header Cache-Control "public, max-age=15552000, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Конфигурация
|
||||
|
||||
Создайте файл `.env` в той же директории, что и `docker-compose.yml`. **Не пропускайте шаг генерации секретов ниже.**
|
||||
|
||||
```bash
|
||||
# --- Учетные данные базы данных ---
|
||||
SHARED_USERNAME=joplin
|
||||
SHARED_PASSWORD=generate_this_with_openssl
|
||||
```
|
||||
|
||||
### Генерация секретов
|
||||
|
||||
Используйте `openssl`, чтобы легко создать безопасные случайные строки для записей в вашем `.env`:
|
||||
|
||||
```bash
|
||||
# Генерация SHARED_PASSWORD
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## 3. Развертывание
|
||||
|
||||
Запустите стек, выполнив следующую команду:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## 4. Начальная настройка сервера
|
||||
|
||||
Перед настройкой веб-приложения необходимо инициализировать ваш Joplin Server:
|
||||
|
||||
Следуйте официальному руководству: [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
|
||||
|
||||
По сути, вам нужно убедиться, что базовая синхронизация через приложение работает.
|
||||
|
||||
## 5. Демонстрация и настройка веб-приложения
|
||||
|
||||
Как только веб-приложение запустится по адресу `https://example.com/app/`, оно поприветствует вас.
|
||||
|
||||
### О веб-приложении
|
||||
|
||||

|
||||
|
||||
По сути, это то же самое мобильное приложение Joplin, но в браузере. Оно использует LocalStorage API для обработки данных. Все по-прежнему работает по принципу local-first, поэтому после входа в систему потребуется время для синхронизации всех удаленных заметок.
|
||||
|
||||
Но самое главное: _Веб-интерфейс Joplin позволяет просматривать и редактировать заметки прямо из браузера._
|
||||
|
||||
> Говорят, что работа гарантирована только в Chrome из-за требований к совместимости API; однако я добился отличных результатов и в Firefox. Браузеры быстро догоняют друг друга.
|
||||
|
||||
### Настройка аутентификации
|
||||
|
||||
Чтобы подключиться к вашему self-hosted серверу Joplin, перейдите в **Settings > Synchronization** и настройте следующие параметры:
|
||||
|
||||
- **Sync Target**: Joplin Server
|
||||
- **Joplin Server URL**: `https://example.com`
|
||||
- **Joplin Server Email**: `your-email@example.com` (тот, который вы только что создали/настроили)
|
||||
- **Joplin Server Password**: `your-password`
|
||||
|
||||

|
||||
|
||||
_Наслаждайтесь!_
|
||||
|
||||
## Ресурсы
|
||||
|
||||
- [Joplin GitHub](https://github.com/laurent22/joplin)
|
||||
- [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
|
||||
@@ -1,182 +1,154 @@
|
||||
---
|
||||
title: 'Xray Subnet Mimicry'
|
||||
description: 'Building a distributed 10.0.0.0/8 private network with Xray-core'
|
||||
createdDate: 2026-03-08
|
||||
tags: ['networking', 'guide']
|
||||
title: 'Xray Subnet Mimicry (10.0.0.0/8)'
|
||||
description: 'Building a distributed private network with Xray-core and reverse tunneling'
|
||||
createdDate: 2026-03-20
|
||||
tags: ['networking', 'xray', 'guide']
|
||||
---
|
||||
|
||||
> This guide is for **educational purposes** only. It is designed as a technical demonstration of Xray-core's routing capabilities and private subnet mimicry. It is NOT intended for restriction evasion or illegal activities. Use this knowledge responsibly and in compliance with your local laws.
|
||||
> **EDUCATIONAL PURPOSES ONLY**
|
||||
> This project is designed as a technical demonstration of Xray-core's routing capabilities and private subnet mimicry. It is NOT intended for restriction evasion or illegal activities. Use this knowledge responsibly and in compliance with your local laws.
|
||||
|
||||
## Why Subnet Mimicry?
|
||||
This guide describes how to deploy a private note-taking infrastructure (or any distributed service) using a contiguous `10.0.0.0/8` private subnet mimicked across distinct servers using **Xray-core** and **reverse tunneling**.
|
||||
|
||||
By constructing a "Flat Network" (Layer 3) over the public internet, you achieve a unified private address space (`10.0.0.0/8`) distributed across distinct servers.
|
||||
## Technical Deep-Dive & Architecture
|
||||
|
||||
- **Distributed Topology**: Each node is an authoritative gateway for its own `/16` segment (e.g., Hub on `10.1.0.0/16`, Spokes on `10.2.0.0/16` and `10.3.0.0/16`).
|
||||
- **NAT Traversal**: Spokes (Bridges) actively initiate connections to the Hub (Portal), allowing nodes behind NAT to be reachable.
|
||||
- **Centralized Resolution**: Enforce network-wide DNS policies through a central Hub resolver.
|
||||
### The Virtual Subnet Concept
|
||||
|
||||
---
|
||||
We are constructing a "Flat Network" (Layer 3) over the public internet. Each node acts as an authoritative gateway for its own `/16` segment of the `10.0.0.0/8` range. This allows seamless communication between servers as if they were on the same local network.
|
||||
|
||||
## Technical Stack Overview
|
||||
### The Science of Interception (TPROXY)
|
||||
|
||||
1. **Xray-core**: The routing engine handling VLESS connections, reverse tunneling, and TPROXY interception.
|
||||
2. **TPROXY**: Transparent proxying in the Linux Mangle table that intercepts TCP/UDP traffic while preserving original destination IPs.
|
||||
3. **Policy Routing**: Using `ip rule` and routing Table 100 to force intercepted packets into the local loopback for Xray to process.
|
||||
4. **Mux.cool**: A multiplexing protocol that packs hundreds of virtual connections (TCP/UDP streams) into a single physical TLS connection.
|
||||
5. **AdGuard Home**: The centralized DNS resolver for the entire subnet.
|
||||
6. **Nginx**: Used as camouflage on the main public inbound port to hide the Xray instance.
|
||||
Unlike `REDIRECT`, which only works for TCP and modifies the destination IP, **TPROXY** (Transparent Proxy) allows Xray to intercept both TCP and UDP while preserving the original packet headers.
|
||||
|
||||
---
|
||||
- **Mechanism**: When a packet destined for `10.x.x.x` enters the `PREROUTING` or `OUTPUT` chain, the Linux Mangle table marks it with `0x1`.
|
||||
- **Preservation**: Xray's `tunnel` inbound uses the `tproxy` socket option to read the original destination IP from the socket metadata, enabling it to route the packet correctly.
|
||||
|
||||
## 1. The Deployment Stack
|
||||
### Policy Routing (Table 100)
|
||||
|
||||
This configuration represents the Central Hub (Server A) which acts as the Portal for Spoke connections. The Hub hosts the centralized DNS and the fallback Nginx camouflage.
|
||||
While firewall marks identify intercepted packets, they don't inherently change the routing path. To redirect this traffic to Xray, we use **Policy Routing**:
|
||||
|
||||
```yaml
|
||||
name: 'a'
|
||||
1. **The Rule (`ip rule`)**: `ip rule add fwmark 1 lookup 100` instructs the networking stack to use a custom routing table for marked packets.
|
||||
2. **The Table (`ip route`)**: Table 100 is configured with `local default dev lo`, treating all destination IPs as local and routing them to the loopback interface.
|
||||
3. **The Handoff**: Xray, listening on `127.0.0.1:12345` with the `tproxy` option, intercepts the packet from the loopback interface.
|
||||
4. **Loop Prevention**: We use `mark: 255` in Xray's outbounds to exempt its own traffic from interception, avoiding infinite routing loops.
|
||||
|
||||
services:
|
||||
xray:
|
||||
image: ghcr.io/xtls/xray-core:26.1.23
|
||||
container_name: a-xray
|
||||
network_mode: host
|
||||
volumes:
|
||||
- ./xray.jsonc:/etc/xray/config.jsonc:ro
|
||||
- /etc/letsencrypt/live:/etc/letsencrypt/live:ro
|
||||
command: -c '/etc/xray/config.jsonc'
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
### The Reverse Proxy Mechanism
|
||||
|
||||
nginx:
|
||||
image: nginxinc/nginx-unprivileged:1.29-alpine
|
||||
container_name: a-nginx
|
||||
networks:
|
||||
direct:
|
||||
ipv4_address: 10.1.1.4
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
To reach servers behind NAT (Spokes) from a central Hub, Xray employs a sophisticated Reverse Proxy architecture:
|
||||
|
||||
adguard:
|
||||
image: adguard/adguardhome:v0.107.72
|
||||
container_name: root-adguard
|
||||
volumes:
|
||||
- adguard-work:/opt/adguardhome/work
|
||||
- adguard-conf:/opt/adguardhome/conf
|
||||
networks:
|
||||
direct:
|
||||
ipv4_address: 10.1.1.3
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
- SYS_NICE
|
||||
- CAP_CHOWN
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
- **Bridge (Spoke)**: Initiates the connection to the Portal. It acts as a client for the tunnel but functionally behaves as a server once the connection is established.
|
||||
- **Portal (Hub)**: Listens for Bridge connections and uses "Dummy Domains" to identify them, relaying traffic through the established tunnels.
|
||||
- **Multiplexing (Mux)**: Uses [Mux.cool](https://xtls.github.io/en/development/protocols/muxcool.html) to pack hundreds of virtual streams into a single physical TLS connection, reducing overhead and bypassing NAT timeouts.
|
||||
|
||||
networks:
|
||||
direct:
|
||||
name: a-direct
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.1.0.0/16 # Using the full /16 as defined for Server A
|
||||
gateway: 10.1.0.1
|
||||
## Setup Guide
|
||||
|
||||
volumes:
|
||||
adguard-work:
|
||||
name: a-adguard-work
|
||||
adguard-conf:
|
||||
name: a-adguard-conf
|
||||
```
|
||||
### Phase 0: Prerequisites (Server A only)
|
||||
|
||||
---
|
||||
Server A (the Hub) requires a valid SSL certificate for XTLS-Vision. Point your domain (`A-DOMAIN`) to Server A's public IP and ensure certificates are available (e.g., via Certbot) at `/etc/letsencrypt/live/A-DOMAIN/`.
|
||||
|
||||
## 2. Configuration & Routing
|
||||
### Phase 1: Security & Identity
|
||||
|
||||
### Generating Identity UUIDs
|
||||
|
||||
Each connection needs its own cryptographic "Key". You need 4 UUIDs mapped to your config files.
|
||||
|
||||
```bash
|
||||
# Generate UUIDs for the Server B and Server C mappings
|
||||
docker run --rm ghcr.io/xtls/xray-core uuid
|
||||
```
|
||||
|
||||
The mapping is as follows:
|
||||
Each connection requires a unique UUID. Generate 4 UUIDs to map the following connections:
|
||||
|
||||
- `UUID_B_REVERSE`: Tunnel control for Server B.
|
||||
- `UUID_B_DIRECT`: Data traffic for Server B.
|
||||
- `UUID_C_REVERSE`: Tunnel control for Server C.
|
||||
- `UUID_C_DIRECT`: Data traffic for Server C.
|
||||
|
||||
### Policy Routing (Table 100)
|
||||
### Phase 2: Host Networking
|
||||
|
||||
Apply the routing configuration script on **EVERY** server node to set up the kernel parameters and routing Table 100. This tricks the kernel into handing the packet to our TPROXY inbound listening on `127.0.0.1:12345`.
|
||||
On **EVERY** server node, perform the following:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
1. **Initialize Routing**: Run the routing setup script to configure kernel parameters and Table 100.
|
||||
2. **Firewall Configuration**: Update `/etc/ufw/before.rules`. Insert the specific node rules immediately after the `*filter` header. These rules handle the `mangle` table logic required for TPROXY.
|
||||
3. **Reload**: Apply changes with `sudo ufw reload`.
|
||||
|
||||
# 1. Enable IP Forwarding
|
||||
sudo sysctl -w net.ipv4.ip_forward=1
|
||||
### Phase 3: Deployment
|
||||
|
||||
# 2. IP Rule for mark 0x1 -> table 100
|
||||
sudo ip rule add fwmark 0x1 lookup 100 priority 100
|
||||
Update your `xray.jsonc` configurations with the generated UUIDs, your domain, and public IPs. Launch the stack using `docker compose up -d`. On Server A, the compose setup will also initialize the internal bridge network for services like AdGuard and Nginx.
|
||||
|
||||
# 3. Local route in table 100
|
||||
sudo ip route add local 0.0.0.0/0 dev lo table 100
|
||||
## Configuration Examples
|
||||
|
||||
### Server A: Hub (10.1.0.0/16)
|
||||
|
||||
The Hub handles reverse tunnel entries and routes traffic to the appropriate Spoke based on the 10.x.x.x range.
|
||||
Full configuration available here: [a/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/xray.jsonc)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"port": 443,
|
||||
"protocol": "vless",
|
||||
"tag": "proxy-direct",
|
||||
"settings": {
|
||||
"clients": [
|
||||
{ "id": "REPLACE_WITH_UUID_B_REVERSE", "reverse": { "tag": "reverse-b" } },
|
||||
{ "id": "REPLACE_WITH_UUID_B_DIRECT" },
|
||||
// ... repeat for other Spokes
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "proxy-enclave",
|
||||
"protocol": "tunnel",
|
||||
"streamSettings": { "sockopt": { "tproxy": "tproxy", "mark": 255 } },
|
||||
},
|
||||
],
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "ip": ["10.2.0.0/16"], "outboundTag": "reverse-b" },
|
||||
{ "ip": ["10.3.0.0/16"], "outboundTag": "reverse-c" },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### Server B: Spoke (10.2.0.0/16)
|
||||
|
||||
## 3. Firewall Interception
|
||||
The Spoke initiates the reverse connection to the Hub and handles local subnet breakout.
|
||||
Full configuration available here: [b/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/xray.jsonc)
|
||||
|
||||
Open `/etc/ufw/before.rules` and insert the specific node rules immediately after the `*filter` header requirements. Below is the configuration for Server A (Hub, `10.1.0.0/16`):
|
||||
|
||||
```text
|
||||
*mangle
|
||||
:XRAY - [0:0]
|
||||
:XRAY_MASK - [0:0]
|
||||
:DIVERT - [0:0]
|
||||
|
||||
# --- PREROUTING CHAIN (Traffic from other containers/nodes) ---
|
||||
-A XRAY -d 10.1.0.0/16 -j RETURN
|
||||
-A XRAY ! -d 10.0.0.0/8 -j RETURN
|
||||
-A XRAY -m mark --mark 0xff -j RETURN
|
||||
-A XRAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
|
||||
-A XRAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
|
||||
-A PREROUTING -j XRAY
|
||||
|
||||
# --- OUTPUT CHAIN (Traffic originating from the server itself) ---
|
||||
-A XRAY_MASK -d 10.1.0.0/16 -j RETURN
|
||||
-A XRAY_MASK ! -d 10.0.0.0/8 -j RETURN
|
||||
-A XRAY_MASK -m mark --mark 0xff -j RETURN
|
||||
-A XRAY_MASK -p udp -j MARK --set-mark 1
|
||||
-A XRAY_MASK -p tcp -j MARK --set-mark 1
|
||||
-A OUTPUT -j XRAY_MASK
|
||||
|
||||
# --- DIVERT Optimization ---
|
||||
-A DIVERT -j MARK --set-mark 1
|
||||
-A DIVERT -j ACCEPT
|
||||
-I PREROUTING -p tcp -m socket -j DIVERT
|
||||
|
||||
COMMIT
|
||||
```jsonc
|
||||
{
|
||||
"outbounds": [
|
||||
{
|
||||
"protocol": "vless",
|
||||
"tag": "forward-a-reverse",
|
||||
"settings": {
|
||||
"address": "A-EXTERNAL-IP",
|
||||
"id": "REPLACE_WITH_UUID_B_REVERSE",
|
||||
"reverse": { "tag": "reverse-b" },
|
||||
},
|
||||
},
|
||||
{
|
||||
"protocol": "vless",
|
||||
"tag": "forward-a",
|
||||
"settings": {
|
||||
"address": "A-EXTERNAL-IP",
|
||||
"id": "REPLACE_WITH_UUID_B_DIRECT",
|
||||
},
|
||||
},
|
||||
],
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "inboundTag": ["proxy-enclave"], "outboundTag": "forward-a" },
|
||||
{ "inboundTag": ["reverse-b"], "outboundTag": "forward-direct" },
|
||||
{ "ip": ["10.2.0.0/16"], "outboundTag": "forward-direct" },
|
||||
{ "ip": ["10.0.0.0/8"], "outboundTag": "forward-a" },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Reload UFW once applied:
|
||||
## Resources & Reference
|
||||
|
||||
```bash
|
||||
sudo ufw reload
|
||||
```
|
||||
For a complete, automated example of this architecture, check out the following repository:
|
||||
|
||||
---
|
||||
- [xray-core-subnet-mimicry on GitHub](https://github.com/deytenit/xray-core-subnet-mimicry/tree/main)
|
||||
|
||||
## Resources
|
||||
Additional technical references:
|
||||
|
||||
- [Xray-core Official Documentation](https://xtls.github.io/)
|
||||
- [Xray Reverse Proxy Documentation](https://xtls.github.io/en/config/reverse.html)
|
||||
- [Project X (Xray) GitHub](https://github.com/XTLS/Xray-core)
|
||||
- [Understanding TPROXY (Cloudflare Blog)](https://blog.cloudflare.com/how-we-built-spectrum/)
|
||||
- [Linux Kernel TPROXY Documentation](https://www.kernel.org/doc/Documentation/networking/tproxy.txt)
|
||||
- [Cloudflare Blog: TPROXY](https://blog.cloudflare.com/how-we-built-spectrum/)
|
||||
@@ -1,182 +1,154 @@
|
||||
---
|
||||
title: 'Мимикрия подсетей Xray'
|
||||
description: 'Построение распределённой приватной сети 10.0.0.0/8 с использованием Xray-core'
|
||||
createdDate: 2026-03-08
|
||||
tags: ['networking', 'guide']
|
||||
title: 'Имитация подсети Xray (10.0.0.0/8)'
|
||||
description: 'Создание распределенной частной сети с использованием Xray-core и обратного туннелирования'
|
||||
createdDate: 2026-03-20
|
||||
tags: ['networking', 'xray', 'guide']
|
||||
---
|
||||
|
||||
> Это руководство предназначено **исключительно для образовательных целей**. Оно демонстрирует маршрутизацию в Xray-core и подход к мимикрии приватной подсети. Материал НЕ предназначен для обхода ограничений или незаконной деятельности. Используйте эти знания ответственно и в соответствии с местным законодательством.
|
||||
> **ТОЛЬКО ДЛЯ ОБРАЗОВАТЕЛЬНЫХ ЦЕЛЕЙ**
|
||||
> Этот проект разработан как техническая демонстрация возможностей маршрутизации Xray-core и имитации частных подсетей. Он НЕ предназначен для обхода ограничений или незаконной деятельности. Используйте эти знания ответственно и в соответствии с местным законодательством.
|
||||
|
||||
## Зачем нужна мимикрия подсети?
|
||||
Это руководство описывает, как развернуть распределенную инфраструктуру (например, для заметок или любого другого сервиса), используя непрерывную частную подсеть `10.0.0.0/8`, имитируемую на различных серверах с помощью **Xray-core** и **обратного туннелирования**.
|
||||
|
||||
Если построить "плоскую сеть" (Layer 3) поверх публичного интернета, можно получить единое приватное адресное пространство (`10.0.0.0/8`), распределенное между несколькими отдельными серверами.
|
||||
## Технический разбор и архитектура
|
||||
|
||||
- **Распределенная топология**: каждый узел выступает в роли основного шлюза для своего сегмента `/16` (например, Hub для `10.1.0.0/16`, Spoke для `10.2.0.0/16` и `10.3.0.0/16`).
|
||||
- **Преодоление NAT**: Spoke-узлы (Bridges) сами инициируют соединения с Hub-узлом (Portal), благодаря чему даже узлы за NAT остаются доступными.
|
||||
- **Централизованное разрешение DNS**: сетевые DNS-политики единообразно применяются через центральный резолвер на Hub.
|
||||
### Концепция виртуальной подсети
|
||||
|
||||
---
|
||||
Мы создаем «плоскую сеть» (Layer 3) поверх публичного интернета. Каждый узел выступает в качестве полномочного шлюза для своего собственного сегмента `/16` из диапазона `10.0.0.0/8`. Это обеспечивает бесперебойную связь между серверами, как если бы они находились в одной локальной сети.
|
||||
|
||||
## Обзор технического стека
|
||||
### Наука перехвата (TPROXY)
|
||||
|
||||
1. **Xray-core**: движок маршрутизации для VLESS-соединений, reverse-туннелей и TPROXY-перехвата.
|
||||
2. **TPROXY**: прозрачное проксирование в таблице Linux Mangle, которое перехватывает TCP/UDP-трафик и сохраняет исходный IP назначения.
|
||||
3. **Policy Routing**: использование `ip rule` и таблицы маршрутизации 100 для принудительной отправки перехваченных пакетов в локальный loopback, где их обрабатывает Xray.
|
||||
4. **Mux.cool**: протокол мультиплексирования, который упаковывает сотни виртуальных соединений (TCP/UDP-потоков) в одно физическое TLS-соединение.
|
||||
5. **AdGuard Home**: централизованный DNS-резолвер для всей подсети.
|
||||
6. **Nginx**: используется как маскировка на основном публичном входном порту, чтобы скрыть инстанс Xray.
|
||||
В отличие от `REDIRECT`, который работает только для TCP и изменяет IP-адрес назначения, **TPROXY** (Transparent Proxy) позволяет Xray перехватывать как TCP, так и UDP, сохраняя при этом исходные заголовки пакетов.
|
||||
|
||||
---
|
||||
- **Механизм**: Когда пакет, предназначенный для `10.x.x.x`, попадает в цепочку `PREROUTING` или `OUTPUT`, таблица Linux Mangle помечает его меткой `0x1`.
|
||||
- **Сохранение**: Входящий поток `tunnel` в Xray использует опцию сокета `tproxy` для чтения исходного IP-адреса назначения из метаданных сокета, что позволяет правильно маршрутизировать пакет.
|
||||
|
||||
## 1. Стек развертывания
|
||||
### Политика маршрутизации (Таблица 100)
|
||||
|
||||
Эта конфигурация описывает центральный Hub (сервер A), который выступает как Portal для подключений Spoke-узлов. На Hub размещены централизованный DNS и fallback-маскировка через Nginx.
|
||||
Хотя метки брандмауэра идентифицируют перехваченные пакеты, они сами по себе не изменяют путь маршрутизации. Чтобы перенаправить этот трафик в Xray, мы используем **Policy Routing**:
|
||||
|
||||
```yaml
|
||||
name: 'a'
|
||||
1. **Правило (`ip rule`)**: `ip rule add fwmark 1 lookup 100` предписывает сетевому стеку использовать пользовательскую таблицу маршрутизации для помеченных пакетов.
|
||||
2. **Таблица (`ip route`)**: Таблица 100 настроена с параметром `local default dev lo`, что заставляет систему рассматривать все IP-адреса назначения как локальные и направлять их на интерфейс петли (loopback).
|
||||
3. **Передача**: Xray, прослушивающий `127.0.0.1:12345` с опцией `tproxy`, перехватывает пакет с интерфейса loopback.
|
||||
4. **Предотвращение петель**: Мы используем `mark: 255` в исходящих потоках Xray, чтобы исключить его собственный трафик из перехвата, предотвращая бесконечные циклы маршрутизации.
|
||||
|
||||
services:
|
||||
xray:
|
||||
image: ghcr.io/xtls/xray-core:26.1.23
|
||||
container_name: a-xray
|
||||
network_mode: host
|
||||
volumes:
|
||||
- ./xray.jsonc:/etc/xray/config.jsonc:ro
|
||||
- /etc/letsencrypt/live:/etc/letsencrypt/live:ro
|
||||
command: -c '/etc/xray/config.jsonc'
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
### Механизм обратного прокси
|
||||
|
||||
nginx:
|
||||
image: nginxinc/nginx-unprivileged:1.29-alpine
|
||||
container_name: a-nginx
|
||||
networks:
|
||||
direct:
|
||||
ipv4_address: 10.1.1.4
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
Для доступа к серверам за NAT (Spokes) из центрального хаба Xray использует сложную архитектуру обратного прокси:
|
||||
|
||||
adguard:
|
||||
image: adguard/adguardhome:v0.107.72
|
||||
container_name: root-adguard
|
||||
volumes:
|
||||
- adguard-work:/opt/adguardhome/work
|
||||
- adguard-conf:/opt/adguardhome/conf
|
||||
networks:
|
||||
direct:
|
||||
ipv4_address: 10.1.1.3
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
- SYS_NICE
|
||||
- CAP_CHOWN
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
- **Bridge (Spoke)**: Инициирует соединение с порталом. Он действует как клиент для туннеля, но функционально ведет себя как сервер после установления соединения.
|
||||
- **Portal (Hub)**: Прослушивает входящие соединения от Bridge и использует «фиктивные домены» (Dummy Domains) для их идентификации, ретранслируя трафик через установленные туннели.
|
||||
- **Мультиплексирование (Mux)**: Использует [Mux.cool](https://xtls.github.io/en/development/protocols/muxcool.html) для упаковки сотен виртуальных потоков в одно физическое TLS-соединение, что снижает накладные расходы и позволяет обходить тайм-ауты NAT.
|
||||
|
||||
networks:
|
||||
direct:
|
||||
name: a-direct
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.1.0.0/16 # Using the full /16 as defined for Server A
|
||||
gateway: 10.1.0.1
|
||||
## Руководство по настройке
|
||||
|
||||
volumes:
|
||||
adguard-work:
|
||||
name: a-adguard-work
|
||||
adguard-conf:
|
||||
name: a-adguard-conf
|
||||
### Этап 0: Предварительные требования (только для сервера А)
|
||||
|
||||
Серверу А (Hub) требуется действующий SSL-сертификат для XTLS-Vision. Направьте ваш домен (`A-DOMAIN`) на публичный IP сервера А и убедитесь, что сертификаты доступны (например, через Certbot) по адресу `/etc/letsencrypt/live/A-DOMAIN/`.
|
||||
|
||||
### Этап 1: Безопасность и идентификация
|
||||
|
||||
Для каждого соединения требуется уникальный UUID. Сгенерируйте 4 UUID для сопоставления следующих соединений:
|
||||
|
||||
- `UUID_B_REVERSE`: Управление туннелем для сервера B.
|
||||
- `UUID_B_DIRECT`: Трафик данных для сервера B.
|
||||
- `UUID_C_REVERSE`: Управление туннелем для сервера C.
|
||||
- `UUID_C_DIRECT`: Трафик данных для сервера C.
|
||||
|
||||
### Этап 2: Настройка хоста
|
||||
|
||||
На **КАЖДОМ** узле сервера выполните следующее:
|
||||
|
||||
1. **Инициализация маршрутизации**: Запустите скрипт настройки маршрутизации для конфигурации параметров ядра и таблицы 100.
|
||||
2. **Настройка брандмауэра**: Обновите `/etc/ufw/before.rules`. Вставьте специфические правила узла сразу после заголовка `*filter`. Эти правила обрабатывают логику таблицы `mangle`, необходимую для TPROXY.
|
||||
3. **Перезагрузка**: Примените изменения с помощью `sudo ufw reload`.
|
||||
|
||||
### Этап 3: Развертывание
|
||||
|
||||
Обновите конфигурации `xray.jsonc`, указав сгенерированные UUID, ваш домен и публичные IP-адреса. Запустите стек с помощью `docker compose up -d`. На сервере А установка compose также инициализирует внутреннюю мостовую сеть для таких сервисов, как AdGuard и Nginx.
|
||||
|
||||
## Примеры конфигурации
|
||||
|
||||
### Сервер A: Хаб (10.1.0.0/16)
|
||||
|
||||
Хаб обрабатывает входы обратных туннелей и маршрутизирует трафик в соответствующий Spoke на основе диапазона 10.x.x.x.
|
||||
Полная конфигурация доступна здесь: [a/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/xray.jsonc)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"port": 443,
|
||||
"protocol": "vless",
|
||||
"tag": "proxy-direct",
|
||||
"settings": {
|
||||
"clients": [
|
||||
{ "id": "REPLACE_WITH_UUID_B_REVERSE", "reverse": { "tag": "reverse-b" } },
|
||||
{ "id": "REPLACE_WITH_UUID_B_DIRECT" },
|
||||
// ... повторите для других Spokes
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "proxy-enclave",
|
||||
"protocol": "tunnel",
|
||||
"streamSettings": { "sockopt": { "tproxy": "tproxy", "mark": 255 } },
|
||||
},
|
||||
],
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "ip": ["10.2.0.0/16"], "outboundTag": "reverse-b" },
|
||||
{ "ip": ["10.3.0.0/16"], "outboundTag": "reverse-c" },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### Сервер B: Узел Spoke (10.2.0.0/16)
|
||||
|
||||
## 2. Конфигурация и маршрутизация
|
||||
Spoke инициирует обратное соединение с Хабом и обрабатывает выход в локальную подсеть.
|
||||
Полная конфигурация доступна здесь: [b/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/xray.jsonc)
|
||||
|
||||
### Генерация UUID-идентификаторов
|
||||
|
||||
Каждому соединению нужен собственный криптографический "ключ". Для сопоставления с конфигами вам понадобятся 4 UUID.
|
||||
|
||||
```bash
|
||||
# Generate UUIDs for the Server B and Server C mappings
|
||||
docker run --rm ghcr.io/xtls/xray-core uuid
|
||||
```jsonc
|
||||
{
|
||||
"outbounds": [
|
||||
{
|
||||
"protocol": "vless",
|
||||
"tag": "forward-a-reverse",
|
||||
"settings": {
|
||||
"address": "A-EXTERNAL-IP",
|
||||
"id": "REPLACE_WITH_UUID_B_REVERSE",
|
||||
"reverse": { "tag": "reverse-b" },
|
||||
},
|
||||
},
|
||||
{
|
||||
"protocol": "vless",
|
||||
"tag": "forward-a",
|
||||
"settings": {
|
||||
"address": "A-EXTERNAL-IP",
|
||||
"id": "REPLACE_WITH_UUID_B_DIRECT",
|
||||
},
|
||||
},
|
||||
],
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "inboundTag": ["proxy-enclave"], "outboundTag": "forward-a" },
|
||||
{ "inboundTag": ["reverse-b"], "outboundTag": "forward-direct" },
|
||||
{ "ip": ["10.2.0.0/16"], "outboundTag": "forward-direct" },
|
||||
{ "ip": ["10.0.0.0/8"], "outboundTag": "forward-a" },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Соответствие такое:
|
||||
## Ресурсы и ссылки
|
||||
|
||||
- `UUID_B_REVERSE`: управляющий туннель для сервера B.
|
||||
- `UUID_B_DIRECT`: пользовательский трафик сервера B.
|
||||
- `UUID_C_REVERSE`: управляющий туннель для сервера C.
|
||||
- `UUID_C_DIRECT`: пользовательский трафик сервера C.
|
||||
Для получения полного автоматизированного примера этой архитектуры посетите следующий репозиторий:
|
||||
|
||||
### Маршрутизация по политикам (таблица 100)
|
||||
- [xray-core-subnet-mimicry на GitHub](https://github.com/deytenit/xray-core-subnet-mimicry/tree/main)
|
||||
|
||||
Примените скрипт маршрутизации на **каждом** серверном узле, чтобы настроить параметры ядра и таблицу маршрутизации 100. Это заставляет ядро передавать пакет в наш TPROXY inbound, который слушает `127.0.0.1:12345`.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 1. Enable IP Forwarding
|
||||
sudo sysctl -w net.ipv4.ip_forward=1
|
||||
|
||||
# 2. IP Rule for mark 0x1 -> table 100
|
||||
sudo ip rule add fwmark 0x1 lookup 100 priority 100
|
||||
|
||||
# 3. Local route in table 100
|
||||
sudo ip route add local 0.0.0.0/0 dev lo table 100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Перехват в firewall
|
||||
|
||||
Откройте `/etc/ufw/before.rules` и сразу после обязательного заголовка `*filter` добавьте правила конкретного узла. Ниже пример для сервера A (Hub, `10.1.0.0/16`):
|
||||
|
||||
```text
|
||||
*mangle
|
||||
:XRAY - [0:0]
|
||||
:XRAY_MASK - [0:0]
|
||||
:DIVERT - [0:0]
|
||||
|
||||
# --- PREROUTING CHAIN (Traffic from other containers/nodes) ---
|
||||
-A XRAY -d 10.1.0.0/16 -j RETURN
|
||||
-A XRAY ! -d 10.0.0.0/8 -j RETURN
|
||||
-A XRAY -m mark --mark 0xff -j RETURN
|
||||
-A XRAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
|
||||
-A XRAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
|
||||
-A PREROUTING -j XRAY
|
||||
|
||||
# --- OUTPUT CHAIN (Traffic originating from the server itself) ---
|
||||
-A XRAY_MASK -d 10.1.0.0/16 -j RETURN
|
||||
-A XRAY_MASK ! -d 10.0.0.0/8 -j RETURN
|
||||
-A XRAY_MASK -m mark --mark 0xff -j RETURN
|
||||
-A XRAY_MASK -p udp -j MARK --set-mark 1
|
||||
-A XRAY_MASK -p tcp -j MARK --set-mark 1
|
||||
-A OUTPUT -j XRAY_MASK
|
||||
|
||||
# --- DIVERT Optimization ---
|
||||
-A DIVERT -j MARK --set-mark 1
|
||||
-A DIVERT -j ACCEPT
|
||||
-I PREROUTING -p tcp -m socket -j DIVERT
|
||||
|
||||
COMMIT
|
||||
```
|
||||
|
||||
После применения перезагрузите UFW:
|
||||
|
||||
```bash
|
||||
sudo ufw reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ресурсы
|
||||
Дополнительные технические материалы:
|
||||
|
||||
- [Официальная документация Xray-core](https://xtls.github.io/)
|
||||
- [Документация Xray Reverse Proxy](https://xtls.github.io/en/config/reverse.html)
|
||||
- [Project X (Xray) на GitHub](https://github.com/XTLS/Xray-core)
|
||||
- [Документация Linux Kernel по TPROXY](https://www.kernel.org/doc/Documentation/networking/tproxy.txt)
|
||||
- [Блог Cloudflare: TPROXY](https://blog.cloudflare.com/how-we-built-spectrum/)
|
||||
- [Понимание TPROXY (блог Cloudflare)](https://blog.cloudflare.com/how-we-built-spectrum/)
|
||||
- [Документация TPROXY в ядре Linux](https://www.kernel.org/doc/Documentation/networking/tproxy.txt)
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import * as TestArticle from './articles/2026/test-article.en.mdx';
|
||||
import * as MatrixServerDeploymentGuide from './articles/2026/matrix-server-deployment-guide.en.mdx';
|
||||
import * as XrayCoreSubnetMimicry from './articles/2026/xray-core-subnet-mimicry.en.mdx';
|
||||
import * as JoplinWebDeploymentGuide from './articles/2026/joplin-web-deployment-guide.en.mdx';
|
||||
|
||||
export interface Article {
|
||||
slug: string;
|
||||
@@ -47,6 +48,10 @@ export const articles: Record<string, Article> = {
|
||||
'2026/matrix-server-deployment-guide'
|
||||
),
|
||||
'xray-core-subnet-mimicry': register(XrayCoreSubnetMimicry, '2026/xray-core-subnet-mimicry'),
|
||||
'joplin-web-deployment-guide': register(
|
||||
JoplinWebDeploymentGuide,
|
||||
'2026/joplin-web-deployment-guide'
|
||||
),
|
||||
};
|
||||
|
||||
export const sortedArticles = Object.values(articles).sort(
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 749 B |
@@ -0,0 +1,43 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { MARKDOWN_COMPONENTS } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
import { articles } from '../../../../content/registry';
|
||||
import { ArticleLayout } from '../../../components/Layout/ArticleLayout';
|
||||
import { createPage } from '../../../lib/core/createPage';
|
||||
|
||||
const article = articles['joplin-web-deployment-guide'];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!article) {
|
||||
throw new Error('Article not found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Joplin web deployment guide article page.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* createPage(JoplinWebDeploymentGuidePage, { title: '...', description: '...' });
|
||||
* ```
|
||||
*/
|
||||
function JoplinWebDeploymentGuidePage(): React.JSX.Element {
|
||||
return (
|
||||
<ArticleLayout
|
||||
createdDate={article.createdDate}
|
||||
currentPath={localePath('/articles/2026/joplin-web-deployment-guide')}
|
||||
description={article.description}
|
||||
tags={article.tags}
|
||||
title={article.title}
|
||||
updatedDate={article.updatedDate}
|
||||
>
|
||||
<article.Component components={MARKDOWN_COMPONENTS} />
|
||||
</ArticleLayout>
|
||||
);
|
||||
}
|
||||
|
||||
createPage(JoplinWebDeploymentGuidePage, {
|
||||
title: `${article.title} - Vladimir Eremin`,
|
||||
description: article.description,
|
||||
});
|
||||
export default JoplinWebDeploymentGuidePage;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createKeyset } from '@ermnvldmr/i18n';
|
||||
|
||||
export const { t } = createKeyset({
|
||||
Articles: { en: 'Articles', ru: 'Статьи' },
|
||||
Home: { en: 'Home', ru: 'Главная' },
|
||||
Updated: { en: 'Updated', ru: 'Обновлено' },
|
||||
});
|
||||
@@ -22,11 +22,11 @@ const meta: Meta<typeof ArticleLayout> = {
|
||||
component: ArticleLayout,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
title: 'Building a Modern Content System with Astro 5',
|
||||
title: 'Building a Modern Content System with Rsbuild',
|
||||
description:
|
||||
"A deep dive into building a scalable, file-based content architecture using Astro's new Content Layer API and MDX.",
|
||||
createdDate: new Date('2026-01-31'),
|
||||
tags: ['engineering', 'astro', 'react'],
|
||||
"A deep dive into building a scalable, file-based content architecture using Rsbuild's MDX support and Rspack.",
|
||||
createdDate: new Date('2026-03-19'),
|
||||
tags: ['engineering', 'rsbuild', 'react'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,9 +45,9 @@ export const Default: Story = {
|
||||
<ArticleLayout {...args}>
|
||||
<Header level={2}>Introduction</Header>
|
||||
<Paragraph>
|
||||
Astro 5 introduces the Content Layer API, a powerful way to fetch content from any source,
|
||||
including local files, remote APIs, and CMSs. In this article, we'll explore how to set
|
||||
up a robust system for articles and projects.
|
||||
Rsbuild provides a modern, high-performance build toolchain based on Rspack. In this
|
||||
article, we'll explore how to set up a robust system for articles and projects using
|
||||
MDX and React.
|
||||
</Paragraph>
|
||||
|
||||
<Blockquote>
|
||||
@@ -56,46 +56,45 @@ export const Default: Story = {
|
||||
</Blockquote>
|
||||
|
||||
<Paragraph>
|
||||
Let's look at some code. Our new <Code>content.config.ts</Code> uses the{' '}
|
||||
<Code>glob</Code> loader to pull in MDX files from outside the <Code>src</Code> directory:
|
||||
Let's look at some code. Our <Code>rsbuild.config.ts</Code> uses the{' '}
|
||||
<Code>pluginMdx</Code> to handle MDX files:
|
||||
</Paragraph>
|
||||
|
||||
<CodeBlock label="content.config.ts">
|
||||
<CodeBlock label="rsbuild.config.ts">
|
||||
<Code>
|
||||
{`// services/www/src/content.config.ts
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
{`import { defineConfig } from '@rsbuild/core';
|
||||
import { pluginReact } from '@rsbuild/plugin-react';
|
||||
import { pluginMdx } from '@rsbuild/plugin-mdx';
|
||||
|
||||
const articles = defineCollection({
|
||||
loader: glob({ pattern: "**/*.mdx", base: "./content/articles" }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
createdDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
pluginReact(),
|
||||
pluginMdx({
|
||||
mdxOptions: {
|
||||
remarkPlugins: [],
|
||||
rehypePlugins: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { articles };`}
|
||||
],
|
||||
});`}
|
||||
</Code>
|
||||
</CodeBlock>
|
||||
|
||||
<Header level={3}>Why Astro 5?</Header>
|
||||
<Header level={3}>Why Rsbuild?</Header>
|
||||
<Paragraph>
|
||||
Astro's zero-JS by default approach is perfect for content-heavy sites. By moving our
|
||||
content outside of the <Code>src</Code> directory, we keep our project clean and organized.
|
||||
Rsbuild's performance is unparalleled thanks to Rspack. By using a Rust-based bundler,
|
||||
we keep our development loop fast and our builds efficient.
|
||||
</Paragraph>
|
||||
|
||||
<List spacing="s" variant="unordered">
|
||||
<List.Item>
|
||||
<Text bold>Better performance:</Text> Only what you need is shipped.
|
||||
<Text bold>Better performance:</Text> Rust-powered builds and HMR.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text bold>Improved developer experience:</Text> Fast HMR and great tooling.
|
||||
<Text bold>Improved developer experience:</Text> Sensible defaults and great tooling.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text bold>Type safety:</Text> Zod schemas validate your frontmatter.
|
||||
<Text bold>Type safety:</Text> Full TypeScript support out of the box.
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { localePath } from '@ermnvldmr/i18n';
|
||||
import { Markdown, Time, Text, PageColumns, PageColumn, Breadcrumbs, HStack } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
import { t } from './ArticleLayout.i18n';
|
||||
import { DefaultLayout } from '../DefaultLayout/DefaultLayout';
|
||||
|
||||
/**
|
||||
@@ -50,7 +51,7 @@ export const ArticleLayout: React.FC<ArticleLayoutProps> = ({
|
||||
}) => {
|
||||
const displayDate = updatedDate ? (
|
||||
<Text color="muted" size="s" type="label">
|
||||
Updated <Time date={updatedDate} />
|
||||
{t('Updated')} <Time date={updatedDate} />
|
||||
</Text>
|
||||
) : (
|
||||
<Time color="muted" date={createdDate} size="s" type="label" />
|
||||
@@ -73,9 +74,9 @@ export const ArticleLayout: React.FC<ArticleLayoutProps> = ({
|
||||
|
||||
const breadcrumbs = (
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item href={localePath('/')}>Home</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Item href={localePath('/')}>{t('Home')}</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator />
|
||||
<Breadcrumbs.Item href={localePath('/articles')}>Articles</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Item href={localePath('/articles')}>{t('Articles')}</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator />
|
||||
<Breadcrumbs.Item isCurrent>{title}</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createKeyset } from '@ermnvldmr/i18n';
|
||||
|
||||
export const { t } = createKeyset({
|
||||
Articles: { en: 'Articles', ru: 'Статьи' },
|
||||
GitHub: { en: 'GitHub', ru: 'ГитХаб' },
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { PageHead as PageHeadOriginal, HStack, Link } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
import { t } from './PageHead.i18n';
|
||||
|
||||
import type { PageHeadStrategy } from '@ermnvldmr/ui';
|
||||
|
||||
/**
|
||||
@@ -34,8 +36,8 @@ export interface PageHeadProps {
|
||||
export const PageHead = ({ heading, caption, breadcrumbs, metadata, strategy }: PageHeadProps) => {
|
||||
const nav = (
|
||||
<HStack gap={4}>
|
||||
<Link href="/articles">Articles</Link>
|
||||
<Link href="https://github.com/deytenit">GitHub</Link>
|
||||
<Link href="/articles">{t('Articles')}</Link>
|
||||
<Link href="https://github.com/deytenit">{t('GitHub')}</Link>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"@ermnvldmr/ui/*": ["../../../packages/ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": [".astro/types.d.ts", "src/**/*", ".storybook/**/*"],
|
||||
"include": ["src/**/*", ".storybook/**/*"],
|
||||
"exclude": ["dist", "coverage", "node_modules", "**/*.config.*"]
|
||||
}
|
||||
Reference in new issue
Block a user