TRIVIAL: Add xray-core-subnet-mimicry article (#4)
This commit is contained in:
1 parent
49b545f1b4
commit
ea35a3d9f5
21 files changed
+762
-56
No files matched your search
@@ -0,0 +1,20 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely casts a React ref to a mutable ref object.
|
||||||
|
* This is a controlled escape hatch for situations where TypeScript's
|
||||||
|
* built-in Ref types are too restrictive (e.g., when syncing refs manually).
|
||||||
|
*
|
||||||
|
* @param ref - The ref to cast
|
||||||
|
* @returns The casted mutable ref
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const mutableRef = castMutableRef<HTMLDivElement>(ref);
|
||||||
|
* mutableRef.current = someElement;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function castMutableRef<T>(ref: any): React.MutableRefObject<T | null> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
return ref as React.MutableRefObject<T | null>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export { castRef } from './castRef/castRef';
|
export { castRef } from './castRef/castRef';
|
||||||
|
export { castMutableRef } from './castRef/castMutableRef';
|
||||||
export { genericMemo } from './genericMemo/genericMemo';
|
export { genericMemo } from './genericMemo/genericMemo';
|
||||||
export { useResizeObserver } from './useResizeObserver/useResizeObserver';
|
export { useResizeObserver } from './useResizeObserver/useResizeObserver';
|
||||||
|
export { useIntersectionObserver } from './useIntersectionObserver/useIntersectionObserver';
|
||||||
export { useScroll } from './useScroll/useScroll';
|
export { useScroll } from './useScroll/useScroll';
|
||||||
export { useLocalStorage } from './useLocalStorage/useLocalStorage';
|
export { useLocalStorage } from './useLocalStorage/useLocalStorage';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
import { useIntersectionObserver } from './useIntersectionObserver';
|
||||||
|
|
||||||
|
describe('useIntersectionObserver', () => {
|
||||||
|
let observerCallback: IntersectionObserverCallback;
|
||||||
|
let observeMock = vi.fn();
|
||||||
|
let unobserveMock = vi.fn();
|
||||||
|
let disconnectMock = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
observeMock = vi.fn();
|
||||||
|
unobserveMock = vi.fn();
|
||||||
|
disconnectMock = vi.fn();
|
||||||
|
|
||||||
|
class MockIntersectionObserver {
|
||||||
|
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||||
|
observerCallback = callback;
|
||||||
|
this.root = options?.root ?? null;
|
||||||
|
this.rootMargin = options?.rootMargin ?? '';
|
||||||
|
this.thresholds = options?.threshold
|
||||||
|
? Array.isArray(options.threshold)
|
||||||
|
? options.threshold
|
||||||
|
: [options.threshold]
|
||||||
|
: [0];
|
||||||
|
}
|
||||||
|
root: Element | Document | null;
|
||||||
|
rootMargin: string;
|
||||||
|
thresholds: readonly number[];
|
||||||
|
takeRecords = vi.fn();
|
||||||
|
observe = observeMock;
|
||||||
|
unobserve = unobserveMock;
|
||||||
|
disconnect = disconnectMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return initial state as not intersecting', () => {
|
||||||
|
const ref = { current: document.createElement('div') };
|
||||||
|
const { result } = renderHook(() => useIntersectionObserver(ref));
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call observe on mount', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const ref = { current: element };
|
||||||
|
renderHook(() => useIntersectionObserver(ref));
|
||||||
|
|
||||||
|
expect(observeMock).toHaveBeenCalledWith(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update isIntersecting when observer triggers', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const ref = { current: element };
|
||||||
|
const { result } = renderHook(() => useIntersectionObserver(ref));
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
target: element,
|
||||||
|
},
|
||||||
|
] as unknown as IntersectionObserverEntry[];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
observerCallback(entries, {} as IntersectionObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disconnect after first intersection if once is true', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const ref = { current: element };
|
||||||
|
renderHook(() => useIntersectionObserver(ref, { once: true }));
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
target: element,
|
||||||
|
},
|
||||||
|
] as unknown as IntersectionObserverEntry[];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
observerCallback(entries, {} as IntersectionObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(disconnectMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not disconnect after first intersection if once is false', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const ref = { current: element };
|
||||||
|
renderHook(() => useIntersectionObserver(ref, { once: false }));
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
target: element,
|
||||||
|
},
|
||||||
|
] as unknown as IntersectionObserverEntry[];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
observerCallback(entries, {} as IntersectionObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(disconnectMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call disconnect on unmount', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const ref = { current: element };
|
||||||
|
const { unmount } = renderHook(() => useIntersectionObserver(ref));
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(disconnectMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not observe if ref is null', () => {
|
||||||
|
const ref = { current: null };
|
||||||
|
renderHook(() => useIntersectionObserver(ref));
|
||||||
|
|
||||||
|
expect(observeMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true immediately if IntersectionObserver is not available', () => {
|
||||||
|
vi.stubGlobal('IntersectionObserver', undefined);
|
||||||
|
const ref = { current: document.createElement('div') };
|
||||||
|
const { result } = renderHook(() => useIntersectionObserver(ref));
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/** Options for useIntersectionObserver */
|
||||||
|
export interface UseIntersectionObserverOptions extends IntersectionObserverInit {
|
||||||
|
/** Whether to disconnect after the first intersection */
|
||||||
|
once?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hook that observes if an element is intersecting with its root.
|
||||||
|
*
|
||||||
|
* @param ref - A React ref to the element to observe.
|
||||||
|
* @param options - IntersectionObserver configuration.
|
||||||
|
* @returns Boolean indicating if the element is currently intersecting.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const MyComponent = () => {
|
||||||
|
* const ref = useRef<HTMLDivElement>(null);
|
||||||
|
* const isVisible = useIntersectionObserver(ref);
|
||||||
|
*
|
||||||
|
* return <div ref={ref}>{isVisible ? 'I am visible!' : 'Scroll down...'}</div>;
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const useIntersectionObserver = <T extends HTMLElement>(
|
||||||
|
ref: React.RefObject<T | null>,
|
||||||
|
{ once = true, ...options }: UseIntersectionObserverOptions = {}
|
||||||
|
): boolean => {
|
||||||
|
const [isIntersecting, setIntersecting] = useState(false);
|
||||||
|
const { root, rootMargin, threshold } = options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
setIntersecting(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observeTarget = ref.current;
|
||||||
|
if (!observeTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
setIntersecting(entry.isIntersecting);
|
||||||
|
if (entry.isIntersecting && once) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root, rootMargin, threshold }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(observeTarget);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [ref, once, root, rootMargin, threshold]);
|
||||||
|
|
||||||
|
return isIntersecting;
|
||||||
|
};
|
||||||
@@ -54,7 +54,7 @@ export const Article = memo(function Article({
|
|||||||
className,
|
className,
|
||||||
}: ArticleProps) {
|
}: ArticleProps) {
|
||||||
return (
|
return (
|
||||||
<Container className={className} href={href} padding={4} onPress={onPress}>
|
<Container border className={className} href={href} padding={4} rounded="md" onPress={onPress}>
|
||||||
<VStack align="stretch" gap={4}>
|
<VStack align="stretch" gap={4}>
|
||||||
<VStack align="stretch" gap={2}>
|
<VStack align="stretch" gap={2}>
|
||||||
<Header level={4}>{headline}</Header>
|
<Header level={4}>{headline}</Header>
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ export const Default: Story = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Interactive: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Breadcrumbs>
|
||||||
|
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
||||||
|
<Breadcrumbs.Separator variant="arrow" />
|
||||||
|
<Breadcrumbs.Item onClick={() => alert('Clicked!')}>Click Me</Breadcrumbs.Item>
|
||||||
|
<Breadcrumbs.Separator variant="arrow" />
|
||||||
|
<Breadcrumbs.Item isCurrent>Current Page</Breadcrumbs.Item>
|
||||||
|
</Breadcrumbs>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
export const WithArrows: Story = {
|
export const WithArrows: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Breadcrumbs } from './Breadcrumbs';
|
import { Breadcrumbs } from './Breadcrumbs';
|
||||||
import { render, screen } from '../../lib/testing';
|
import { render, screen, fireEvent } from '../../lib/testing';
|
||||||
import { Link } from '../Link/Link';
|
import { Link } from '../Link/Link';
|
||||||
|
|
||||||
describe('components/Breadcrumbs', () => {
|
describe('components/Breadcrumbs', () => {
|
||||||
@@ -12,14 +12,58 @@ describe('components/Breadcrumbs', () => {
|
|||||||
<Link href="/">Home</Link>
|
<Link href="/">Home</Link>
|
||||||
</Breadcrumbs.Item>
|
</Breadcrumbs.Item>
|
||||||
<Breadcrumbs.Separator variant="slash" />
|
<Breadcrumbs.Separator variant="slash" />
|
||||||
<Breadcrumbs.Item>Current</Breadcrumbs.Item>
|
<Breadcrumbs.Item isCurrent>Current</Breadcrumbs.Item>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Breadcrumb');
|
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Breadcrumb');
|
||||||
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
|
||||||
expect(screen.getByText('/')).toBeInTheDocument();
|
expect(screen.getByText('/')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Current')).toBeInTheDocument();
|
|
||||||
|
const currentItem = screen.getByText('Current').parentElement;
|
||||||
|
expect(currentItem).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports isCurrent state', () => {
|
||||||
|
render(
|
||||||
|
<Breadcrumbs>
|
||||||
|
<Breadcrumbs.Item isCurrent>Current Page</Breadcrumbs.Item>
|
||||||
|
</Breadcrumbs>
|
||||||
|
);
|
||||||
|
const item = screen.getByText('Current Page').parentElement;
|
||||||
|
expect(item).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports href directly on Item', () => {
|
||||||
|
render(
|
||||||
|
<Breadcrumbs>
|
||||||
|
<Breadcrumbs.Item href="/test-url">Test Link</Breadcrumbs.Item>
|
||||||
|
</Breadcrumbs>
|
||||||
|
);
|
||||||
|
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||||
|
expect(link).toHaveAttribute('href', '/test-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports onClick on Item', () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<Breadcrumbs>
|
||||||
|
<Breadcrumbs.Item onClick={handleClick}>Clickable</Breadcrumbs.Item>
|
||||||
|
</Breadcrumbs>
|
||||||
|
);
|
||||||
|
const button = screen.getByRole('button', { name: 'Clickable' });
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders as a non-submit button when onClick is provided', () => {
|
||||||
|
render(
|
||||||
|
<Breadcrumbs>
|
||||||
|
<Breadcrumbs.Item onClick={vi.fn()}>Clickable</Breadcrumbs.Item>
|
||||||
|
</Breadcrumbs>
|
||||||
|
);
|
||||||
|
const button = screen.getByRole('button', { name: 'Clickable' });
|
||||||
|
expect(button).toHaveAttribute('type', 'button');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports different separator variants', () => {
|
it('supports different separator variants', () => {
|
||||||
|
|||||||
@@ -29,20 +29,57 @@ const BreadcrumbsRoot = memo(function Breadcrumbs({ children, ...props }: Breadc
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the individual Breadcrumbs item.
|
||||||
|
*/
|
||||||
|
export interface BreadcrumbsItemProps extends TestIdProps {
|
||||||
|
/** Content to be rendered inside the item */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Optional className for the list item */
|
||||||
|
className?: string;
|
||||||
|
/** Whether this is the current page */
|
||||||
|
isCurrent?: boolean;
|
||||||
|
/** Optional href for the item (renders as a link) */
|
||||||
|
href?: string;
|
||||||
|
/** Optional click handler for the item */
|
||||||
|
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Individual step in the breadcrumb trail.
|
* Individual step in the breadcrumb trail.
|
||||||
*/
|
*/
|
||||||
const BreadcrumbsItem = memo(function BreadcrumbsItem({
|
const BreadcrumbsItem = memo(function BreadcrumbsItem({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
isCurrent,
|
||||||
|
href,
|
||||||
|
onClick,
|
||||||
'data-testid': testId,
|
'data-testid': testId,
|
||||||
}: {
|
}: BreadcrumbsItemProps) {
|
||||||
children: React.ReactNode;
|
const isClickable = !!(href ?? onClick);
|
||||||
className?: string;
|
|
||||||
} & TestIdProps) {
|
|
||||||
return (
|
return (
|
||||||
<li className={className} data-testid={testId}>
|
<li
|
||||||
<Text color="muted" size="m" type="label">
|
aria-current={isCurrent ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center transition-all duration-200',
|
||||||
|
!isCurrent && isClickable && 'cursor-pointer',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
as={href ? 'a' : onClick ? 'button' : 'span'}
|
||||||
|
className={cn(
|
||||||
|
'transition-all duration-200',
|
||||||
|
!isCurrent && 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4'
|
||||||
|
)}
|
||||||
|
color={isCurrent ? 'default' : 'muted'}
|
||||||
|
href={href}
|
||||||
|
size="m"
|
||||||
|
type="label"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
import { Link } from './Link';
|
import { Link } from './Link';
|
||||||
import { render, screen } from '../../lib/testing';
|
import { render, screen } from '../../lib/testing';
|
||||||
|
|
||||||
describe('components/Link', () => {
|
describe('components/Link', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock IntersectionObserver to avoid errors in Text component
|
||||||
|
class MockIntersectionObserver {
|
||||||
|
observe = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders correctly as a link when href is provided', () => {
|
it('renders correctly as a link when href is provided', () => {
|
||||||
render(<Link href="https://example.com">Click me</Link>);
|
render(<Link href="https://example.com">Click me</Link>);
|
||||||
const link = screen.getByRole('link', { name: 'Click me' });
|
const link = screen.getByRole('link', { name: 'Click me' });
|
||||||
@@ -44,4 +58,14 @@ describe('components/Link', () => {
|
|||||||
render(<Link onPress={() => {}}>Click me</Link>);
|
render(<Link onPress={() => {}}>Click me</Link>);
|
||||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes delay prop to the underlying Text component', () => {
|
||||||
|
render(
|
||||||
|
<Link delay={500} href="/test">
|
||||||
|
Delayed Link
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveClass('opacity-0');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -24,6 +24,8 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
|
|||||||
size?: TextSize;
|
size?: TextSize;
|
||||||
/** Text color variant */
|
/** Text color variant */
|
||||||
color?: TextColor;
|
color?: TextColor;
|
||||||
|
/** Delay in milliseconds before the link appears after entering the viewport. */
|
||||||
|
delay?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +35,7 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
|
|||||||
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
|
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
|
||||||
*/
|
*/
|
||||||
export const Link = memo(function Link(props: LinkProps) {
|
export const Link = memo(function Link(props: LinkProps) {
|
||||||
const { children, className, href, isExternal, type, size, color, ...otherProps } = props;
|
const { children, className, href, isExternal, type, size, color, delay, ...otherProps } = props;
|
||||||
|
|
||||||
// We use a specific ref type that useLink expects.
|
// We use a specific ref type that useLink expects.
|
||||||
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
|
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
|
||||||
@@ -59,6 +61,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
|||||||
as="a"
|
as="a"
|
||||||
className={sharedClasses}
|
className={sharedClasses}
|
||||||
color={color}
|
color={color}
|
||||||
|
delay={delay}
|
||||||
href={href}
|
href={href}
|
||||||
size={size}
|
size={size}
|
||||||
type={type}
|
type={type}
|
||||||
@@ -75,6 +78,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
|||||||
as="span"
|
as="span"
|
||||||
className={sharedClasses}
|
className={sharedClasses}
|
||||||
color={color}
|
color={color}
|
||||||
|
delay={delay}
|
||||||
size={size}
|
size={size}
|
||||||
type={type}
|
type={type}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe('Markdown Components', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Heading 1')).toBeInTheDocument();
|
expect(screen.getByText('Heading 1')).toBeInTheDocument();
|
||||||
expect(screen.getByText('This is a paragraph.')).toBeInTheDocument();
|
expect(screen.getByText('This is a paragraph.')).toHaveClass('mb-4');
|
||||||
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com');
|
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com');
|
||||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { List } from '../../List/List';
|
import { List } from '../../List/List';
|
||||||
|
import { MarkdownP } from '../MarkdownP/MarkdownP';
|
||||||
|
|
||||||
import type { ListItemProps } from '../../List/List';
|
import type { ListItemProps } from '../../List/List';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Markdown list item component.
|
* A Markdown list item component.
|
||||||
*/
|
*/
|
||||||
export const MarkdownLI = memo(function MarkdownLI(props: ListItemProps) {
|
export const MarkdownLI = memo(function MarkdownLI({ children, ...props }: ListItemProps) {
|
||||||
return <List.Item {...props} />;
|
return (
|
||||||
|
<List.Item {...props}>
|
||||||
|
<MarkdownP>{children}</MarkdownP>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -1,14 +1,90 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
import { Text } from './Text';
|
import { Text } from './Text';
|
||||||
import { render, screen } from '../../lib/testing';
|
import { render, screen, act } from '../../lib/testing';
|
||||||
|
|
||||||
describe('components/Text', () => {
|
describe('components/Text', () => {
|
||||||
|
let intersectionCallback: (entries: any[]) => void;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
// Mock IntersectionObserver
|
||||||
|
class MockIntersectionObserver {
|
||||||
|
constructor(public callback: (entries: any[]) => void) {
|
||||||
|
intersectionCallback = callback;
|
||||||
|
}
|
||||||
|
observe = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders children correctly', () => {
|
it('renders children correctly', () => {
|
||||||
render(<Text>Test content</Text>);
|
render(<Text>Test content</Text>);
|
||||||
expect(screen.getByText('Test content')).toBeInTheDocument();
|
expect(screen.getByText('Test content')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('is immediately visible when no delay is provided', () => {
|
||||||
|
render(<Text>No delay</Text>);
|
||||||
|
const el = screen.getByText('No delay');
|
||||||
|
expect(el).not.toHaveClass('opacity-0');
|
||||||
|
expect(el).not.toHaveClass('animate-in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is initially hidden when delay is provided', () => {
|
||||||
|
render(<Text delay={500}>With delay</Text>);
|
||||||
|
const el = screen.getByText('With delay');
|
||||||
|
expect(el).toHaveClass('opacity-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('becomes visible after intersection and specified delay', () => {
|
||||||
|
render(<Text delay={500}>Delayed visible</Text>);
|
||||||
|
const el = screen.getByText('Delayed visible');
|
||||||
|
|
||||||
|
expect(el).toHaveClass('opacity-0');
|
||||||
|
|
||||||
|
// Simulate intersection
|
||||||
|
act(() => {
|
||||||
|
intersectionCallback([{ isIntersecting: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should still be hidden before delay
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(250);
|
||||||
|
});
|
||||||
|
expect(el).toHaveClass('opacity-0');
|
||||||
|
|
||||||
|
// Should be visible after delay
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(250);
|
||||||
|
});
|
||||||
|
expect(el).not.toHaveClass('opacity-0');
|
||||||
|
expect(el).toHaveClass('animate-in');
|
||||||
|
expect(el).toHaveClass('fade-in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('becomes visible immediately after intersection if delay is 0', () => {
|
||||||
|
render(<Text delay={0}>Zero delay</Text>);
|
||||||
|
const el = screen.getByText('Zero delay');
|
||||||
|
|
||||||
|
expect(el).toHaveClass('opacity-0');
|
||||||
|
|
||||||
|
// Simulate intersection
|
||||||
|
act(() => {
|
||||||
|
intersectionCallback([{ isIntersecting: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(el).not.toHaveClass('opacity-0');
|
||||||
|
expect(el).toHaveClass('animate-in');
|
||||||
|
});
|
||||||
|
|
||||||
it('applies alignment classes', () => {
|
it('applies alignment classes', () => {
|
||||||
render(<Text align="center">Centered</Text>);
|
render(<Text align="center">Centered</Text>);
|
||||||
expect(screen.getByText('Centered')).toHaveClass('text-center');
|
expect(screen.getByText('Centered')).toHaveClass('text-center');
|
||||||
@@ -39,4 +115,18 @@ describe('components/Text', () => {
|
|||||||
expect(el).toHaveClass('line-clamp-2');
|
expect(el).toHaveClass('line-clamp-2');
|
||||||
expect(el).not.toHaveClass('truncate');
|
expect(el).not.toHaveClass('truncate');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies type="button" by default when as="button"', () => {
|
||||||
|
render(<Text as="button">Button Text</Text>);
|
||||||
|
expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows overriding type attribute with htmlType', () => {
|
||||||
|
render(
|
||||||
|
<Text as="button" htmlType="submit">
|
||||||
|
Submit Text
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
import { cn, genericMemo, useIntersectionObserver, castMutableRef } from '@ermnvldmr/stl';
|
||||||
import React, { forwardRef } from 'react';
|
import React, { forwardRef, useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||||
|
|
||||||
@@ -60,6 +60,10 @@ export interface TextProps
|
|||||||
overflow?: 'ellipsis' | 'clip';
|
overflow?: 'ellipsis' | 'clip';
|
||||||
/** Maximum number of lines to show (truncates with ellipsis) */
|
/** Maximum number of lines to show (truncates with ellipsis) */
|
||||||
maxLines?: number;
|
maxLines?: number;
|
||||||
|
/** Delay in milliseconds before the text appears after entering the viewport. */
|
||||||
|
delay?: number;
|
||||||
|
/** Underlying element's HTML type attribute (e.g. "button", "submit") */
|
||||||
|
htmlType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,13 +86,45 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
|||||||
wrap,
|
wrap,
|
||||||
overflow,
|
overflow,
|
||||||
maxLines,
|
maxLines,
|
||||||
|
delay,
|
||||||
as: Component = 'span',
|
as: Component = 'span',
|
||||||
|
htmlType,
|
||||||
className,
|
className,
|
||||||
'data-testid': testId,
|
'data-testid': testId,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
|
const [isVisible, setIsVisible] = useState(delay === undefined);
|
||||||
|
const internalRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
// Sync internalRef with forwarded ref
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref) return;
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(internalRef.current);
|
||||||
|
} else {
|
||||||
|
castMutableRef<HTMLElement>(ref).current = internalRef.current;
|
||||||
|
}
|
||||||
|
}, [ref]);
|
||||||
|
|
||||||
|
const isIntersecting = useIntersectionObserver(internalRef, {
|
||||||
|
threshold: 0.1,
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (delay !== undefined && isIntersecting) {
|
||||||
|
if (delay > 0) {
|
||||||
|
const timer = setTimeout(() => setIsVisible(true), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else {
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [delay, isIntersecting]);
|
||||||
|
|
||||||
// Base typography styles mapping
|
// Base typography styles mapping
|
||||||
const typeStyles: Record<TextType, Record<TextSize, string>> = {
|
const typeStyles: Record<TextType, Record<TextSize, string>> = {
|
||||||
display: {
|
display: {
|
||||||
@@ -152,7 +188,7 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
|||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
{...props}
|
{...props}
|
||||||
ref={castRef<HTMLElement>(ref)}
|
ref={internalRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
typeStyles[type][size],
|
typeStyles[type][size],
|
||||||
colorClasses[color],
|
colorClasses[color],
|
||||||
@@ -163,9 +199,14 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
|||||||
align && alignClasses[align],
|
align && alignClasses[align],
|
||||||
wrap && wrapClasses[wrap],
|
wrap && wrapClasses[wrap],
|
||||||
truncationClass,
|
truncationClass,
|
||||||
|
delay !== undefined &&
|
||||||
|
(isVisible
|
||||||
|
? 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out'
|
||||||
|
: 'opacity-0'),
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
|
type={htmlType ?? (Component === 'button' ? 'button' : undefined)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Component>
|
</Component>
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
---
|
||||||
|
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']
|
||||||
|
---
|
||||||
|
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
## Why Subnet Mimicry?
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Stack Overview
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The Deployment Stack
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: 'a'
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Configuration & Routing
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|
- `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)
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
```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 Interception
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload UFW once applied:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw reload
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [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)
|
||||||
|
- [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,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as TestArticle from './articles/2026/test-article.mdx';
|
import * as TestArticle from './articles/2026/test-article.mdx';
|
||||||
import * as MatrixServerDeploymentGuide from './articles/2026/matrix-server-deployment-guide.mdx';
|
import * as MatrixServerDeploymentGuide from './articles/2026/matrix-server-deployment-guide.mdx';
|
||||||
|
import * as XrayCoreSubnetMimicry from './articles/2026/xray-core-subnet-mimicry.mdx';
|
||||||
|
|
||||||
export interface Article {
|
export interface Article {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -45,6 +46,7 @@ export const articles: Record<string, Article> = {
|
|||||||
MatrixServerDeploymentGuide,
|
MatrixServerDeploymentGuide,
|
||||||
'2026/matrix-server-deployment-guide'
|
'2026/matrix-server-deployment-guide'
|
||||||
),
|
),
|
||||||
|
'xray-core-subnet-mimicry': register(XrayCoreSubnetMimicry, '2026/xray-core-subnet-mimicry'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sortedArticles = Object.values(articles).sort(
|
export const sortedArticles = Object.values(articles).sort(
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ import { createPage } from '../../../lib/core/createPage';
|
|||||||
|
|
||||||
const article = articles['test-article'];
|
const article = articles['test-article'];
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
||||||
if (!article) {
|
|
||||||
throw new Error('Article not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
createPage(
|
createPage(
|
||||||
function TestArticlePage(): React.JSX.Element {
|
function TestArticlePage(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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['xray-core-subnet-mimicry'];
|
||||||
|
|
||||||
|
createPage(
|
||||||
|
function XrayCoreSubnetMimicryPage(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<ArticleLayout
|
||||||
|
createdDate={article.createdDate}
|
||||||
|
description={article.description}
|
||||||
|
tags={article.tags}
|
||||||
|
title={article.title}
|
||||||
|
updatedDate={article.updatedDate}
|
||||||
|
>
|
||||||
|
<article.Component components={MARKDOWN_COMPONENTS} />
|
||||||
|
</ArticleLayout>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${article.title} - Vladimir Eremin`,
|
||||||
|
description: article.description,
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Separator, Article, VStack, Breadcrumbs } from '@ermnvldmr/ui';
|
import { Article, Breadcrumbs } from '@ermnvldmr/ui';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { sortedArticles } from '../../../content/registry';
|
import { sortedArticles } from '../../../content/registry';
|
||||||
@@ -10,11 +10,9 @@ createPage(
|
|||||||
function ArticlesList(): React.JSX.Element {
|
function ArticlesList(): React.JSX.Element {
|
||||||
const breadcrumbs = (
|
const breadcrumbs = (
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Breadcrumbs.Item>
|
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
||||||
<a href="/">Home</a>
|
|
||||||
</Breadcrumbs.Item>
|
|
||||||
<Breadcrumbs.Separator />
|
<Breadcrumbs.Separator />
|
||||||
<Breadcrumbs.Item>Articles</Breadcrumbs.Item>
|
<Breadcrumbs.Item isCurrent>Articles</Breadcrumbs.Item>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -25,15 +23,9 @@ createPage(
|
|||||||
paddingY="medium"
|
paddingY="medium"
|
||||||
title="Articles"
|
title="Articles"
|
||||||
>
|
>
|
||||||
<VStack className="w-full" gap={8}>
|
|
||||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
{sortedArticles.map((article, index) => (
|
{sortedArticles.map((article) => (
|
||||||
<React.Fragment key={article.slug}>
|
<React.Fragment key={article.slug}>
|
||||||
{index > 0 && index % 2 === 0 && (
|
|
||||||
<div className="col-span-1 py-8 md:col-span-2">
|
|
||||||
<Separator className="w-full" type="double" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Article
|
<Article
|
||||||
additionalText={article.tags?.join(' • ')}
|
additionalText={article.tags?.join(' • ')}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
@@ -50,7 +42,6 @@ createPage(
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</VStack>
|
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,24 +9,56 @@ createPage(
|
|||||||
function HomePage(): React.JSX.Element {
|
function HomePage(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<DefaultLayout centerVertically headerAddonLeft={<Header level={3}>{SITE_TITLE}</Header>}>
|
<DefaultLayout centerVertically headerAddonLeft={<Header level={3}>{SITE_TITLE}</Header>}>
|
||||||
<VStack gap={4}>
|
<VStack gap={24}>
|
||||||
<Header level={2}>Welcome to my page!</Header>
|
<Header delay={0} level={1}>
|
||||||
<Paragraph>
|
Welcome to my corner of the internet.
|
||||||
If you see this page, my website is successfuly installed and working.
|
</Header>
|
||||||
|
<VStack gap={12}>
|
||||||
|
<Paragraph delay={300} size="l" type="title">
|
||||||
|
You are arrived right as the lights turned on. The core foundation is solid and the
|
||||||
|
site is officially live,
|
||||||
<br />
|
<br />
|
||||||
It will be further configured.
|
but the metaphorical boxes are still being unpacked. Expect this space to evolve
|
||||||
|
continuously as everything finds its proper place.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<Paragraph>
|
<Paragraph delay={350} size="l" type="title">
|
||||||
For the source code and support please refer to{' '}
|
Sharing the underlying architecture is a core philosophy here. If you are curious
|
||||||
<Link href="https://github.com/deytenit/ermnvldmr.com/">ermnvldmr.com monorepo</Link>.
|
about the specifics of how it is all built,
|
||||||
<br />
|
<br />
|
||||||
Pictures of mine are available at{' '}
|
you can inspect the{' '}
|
||||||
<Link href="https://ermnvldmr.com/instagram">@deytenit</Link>.
|
<Link href="https://github.com/deytenit/ermnvldmr.com/" size="l" type="title">
|
||||||
|
ermnvldmr.com repository
|
||||||
|
</Link>{' '}
|
||||||
|
to see the complete blueprints.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<Paragraph>
|
<Paragraph delay={400} size="l" type="title">
|
||||||
Check out my <Link href="/articles">thoughts and articles on various matters</Link>.
|
For a look at the world away from terminal screens and code editors, the{' '}
|
||||||
|
<Link href="https://ermnvldmr.com/instagram" size="l" type="title">
|
||||||
|
@deytenit
|
||||||
|
</Link>{' '}
|
||||||
|
visual diary captures those moments.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<Paragraph italic>Thank you for visiting!</Paragraph>
|
<Paragraph delay={450} size="l" type="title">
|
||||||
|
Whenever you are ready to look around, feel free to dive into{' '}
|
||||||
|
<Link href="/articles" size="l" type="title">
|
||||||
|
the growing collection of articles
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
<br />
|
||||||
|
It is a dedicated space covering everything from technical deep-dives to everyday
|
||||||
|
reflections.
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph delay={500} size="l" type="title">
|
||||||
|
Even though the rest of the environment is still a work in progress, the communication
|
||||||
|
lines are fully operational.
|
||||||
|
<br />
|
||||||
|
Whether you want to discuss tech, share a perspective on a recent read, or simply make
|
||||||
|
a connection, the mailbox is open at...
|
||||||
|
</Paragraph>
|
||||||
|
</VStack>
|
||||||
|
<Link delay={1000} href="mailto:personal@ermnvldmr.com" size="l" type="title">
|
||||||
|
personal@ermnvldmr.com
|
||||||
|
</Link>
|
||||||
</VStack>
|
</VStack>
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,15 +69,11 @@ export const ArticleLayout: React.FC<ArticleLayoutProps> = ({
|
|||||||
|
|
||||||
const breadcrumbs = (
|
const breadcrumbs = (
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Breadcrumbs.Item>
|
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
||||||
<a href="/">Home</a>
|
|
||||||
</Breadcrumbs.Item>
|
|
||||||
<Breadcrumbs.Separator />
|
<Breadcrumbs.Separator />
|
||||||
<Breadcrumbs.Item>
|
<Breadcrumbs.Item href="/articles">Articles</Breadcrumbs.Item>
|
||||||
<a href="/articles">Articles</a>
|
|
||||||
</Breadcrumbs.Item>
|
|
||||||
<Breadcrumbs.Separator />
|
<Breadcrumbs.Separator />
|
||||||
<Breadcrumbs.Item>{title}</Breadcrumbs.Item>
|
<Breadcrumbs.Item isCurrent>{title}</Breadcrumbs.Item>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user