Various build shenenigans (#5)

This PR extends the build pipeline of the www service to support
locale-prefixed static builds (EN/RU) with build-time SSG output
injection, while also refactoring UI typography to a tokenized Tailwind
v4 @theme-driven approach.

Changes:

- Add build-time SSG plugin to @ermnvldmr/rsbuild-config and wire
multi-locale builds (/en/, /ru/) into services/www.
- Introduce new @ermnvldmr/i18n package (locale constants, path helpers,
keysets, pluralization) and apply it to www navigation/text.
- Refactor ui typography to a shared getTypographyClassNames utility +
Tailwind token definitions, with stl/cn updated for correct
Tailwind-merge behavior.
This commit is contained in:
deytenit authored and GitHub committed 2026-03-16 00:00:20 +03:00
1 parent ac9c455e5a
commit b84ef775b8
68 files changed
+2991 -397

No files matched your search

@@ -41,7 +41,7 @@ describe('components/Breadcrumbs', () => {
</Breadcrumbs>
);
const link = screen.getByRole('link', { name: 'Test Link' });
expect(link).toHaveAttribute('href', '/test-url');
expect(link).toHaveAttribute('href', '/en/test-url');
});
it('supports onClick on Item', () => {
@@ -1,3 +1,4 @@
import { localePath } from '@ermnvldmr/i18n';
import { cn } from '@ermnvldmr/stl';
import { ChevronRight } from 'lucide-react';
import React, { memo } from 'react';
@@ -56,7 +57,8 @@ const BreadcrumbsItem = memo(function BreadcrumbsItem({
onClick,
'data-testid': testId,
}: BreadcrumbsItemProps) {
const isClickable = !!(href ?? onClick);
const resolvedHref = localePath(href);
const isClickable = !!(resolvedHref ?? onClick);
return (
<li
@@ -69,13 +71,13 @@ const BreadcrumbsItem = memo(function BreadcrumbsItem({
data-testid={testId}
>
<Text
as={href ? 'a' : onClick ? 'button' : 'span'}
as={resolvedHref ? 'a' : onClick ? 'button' : 'span'}
className={cn(
'transition-all duration-200',
!isCurrent && 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4'
)}
color={isCurrent ? 'default' : 'muted'}
href={href}
href={resolvedHref}
size="m"
type="label"
onClick={onClick}
+3 -1
View File
@@ -1,3 +1,4 @@
import { localePath } from '@ermnvldmr/i18n';
import { castRef, cn } from '@ermnvldmr/stl';
import React, { memo, useRef } from 'react';
import { useLink } from 'react-aria';
@@ -50,6 +51,7 @@ export const Link = memo(function Link(props: LinkProps) {
);
if (href) {
const resolvedHref = localePath(href);
const isAutoExternal = isExternal ?? href.startsWith('http');
const externalProps = isAutoExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {};
@@ -62,7 +64,7 @@ export const Link = memo(function Link(props: LinkProps) {
className={sharedClasses}
color={color}
delay={delay}
href={href}
href={resolvedHref}
size={size}
type={type}
>
@@ -127,20 +127,22 @@ export const PageHead = ({
<>
<header ref={headerRef} className={rootClasses}>
<div
className={cn('transition-all duration-300', {
'opacity-0 invisible h-0 overflow-hidden': isCollapsed,
'opacity-100 visible': !isCollapsed,
})}
className={cn(
'grid transition-[grid-template-rows] duration-300 ease-in-out',
isCollapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'
)}
inert={isCollapsed || undefined}
>
{expandedContent}
<div className="overflow-hidden">{expandedContent}</div>
</div>
<div
className={cn('transition-all duration-300', {
'opacity-100 visible': isCollapsed,
'opacity-0 invisible h-0 overflow-hidden': !isCollapsed,
})}
className={cn(
'grid transition-[grid-template-rows] duration-300 ease-in-out',
isCollapsed ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
inert={!isCollapsed || undefined}
>
{collapsedContent}
<div className="overflow-hidden">{collapsedContent}</div>
</div>
</header>
<div style={{ height }} />
@@ -129,4 +129,32 @@ describe('components/Text', () => {
);
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
});
// TDD: fails before implementation (old code uses text-[14px], not text-rb-body-m)
it('applies text-rb-{type}-{size} class', () => {
render(<Text size="m" type="body">Content</Text>);
expect(screen.getByText('Content')).toHaveClass('text-rb-body-m');
});
it('does not apply legacy px-based font-size classes', () => {
render(<Text size="m" type="body">Content</Text>);
expect(screen.getByText('Content')).not.toHaveClass('text-[14px]');
expect(screen.getByText('Content')).not.toHaveClass('leading-[20px]');
});
// Regression guards: pass before and after
it('applies font-medium for label type', () => {
render(<Text size="m" type="label">Label</Text>);
expect(screen.getByText('Label')).toHaveClass('font-medium');
});
it('applies font-sans for body type', () => {
render(<Text size="m" type="body">Body</Text>);
expect(screen.getByText('Body')).toHaveClass('font-sans');
});
it('applies font-serif for display type', () => {
render(<Text size="l" type="display">Display</Text>);
expect(screen.getByText('Display')).toHaveClass('font-serif');
});
});
+17 -96
View File
@@ -1,29 +1,19 @@
import { cn, genericMemo, useIntersectionObserver, castMutableRef } from '@ermnvldmr/stl';
import React, { forwardRef, useState, useEffect, useRef } from 'react';
import {
getTypographyClassNames,
type TextType,
type TextSize,
type TextColor,
type TextAlign,
type TextWrap,
type TextOverflow,
} from '../../lib/typography';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Typography types.
*/
export type TextType = 'display' | 'headline' | 'title' | 'body' | 'label';
/**
* Typography sizes.
*/
export type TextSize = 's' | 'm' | 'l';
/**
* Text color variants.
*/
export type TextColor =
| 'default'
| 'primary'
| 'secondary'
| 'tertiary'
| 'error'
| 'muted'
| 'inherit';
export type { TextType, TextSize, TextColor, TextAlign, TextWrap, TextOverflow };
/**
* Props for the Text component.
@@ -53,11 +43,11 @@ export interface TextProps
/** Optional href for links */
href?: string;
/** Text alignment */
align?: 'left' | 'center' | 'right' | 'justify';
align?: TextAlign;
/** Text wrapping behavior */
wrap?: 'nowrap' | 'balance' | 'pretty';
wrap?: TextWrap;
/** Overflow behavior for single lines */
overflow?: 'ellipsis' | 'clip';
overflow?: TextOverflow;
/** Maximum number of lines to show (truncates with ellipsis) */
maxLines?: number;
/** Delay in milliseconds before the text appears after entering the viewport. */
@@ -69,8 +59,8 @@ export interface TextProps
/**
* A flexible typography component that implements the design system's type scale.
*
* Text centralizes all typography rules, including font families, sizes,
* line heights, and letter spacing, reducing the need for custom CSS.
* Text centralizes all typography rules — font families, sizes, line heights,
* letter spacing, colors, and decorations — via the getTypographyClassNames utility.
*/
const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
{
@@ -98,7 +88,6 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
const [isVisible, setIsVisible] = useState(delay === undefined);
const internalRef = useRef<HTMLElement>(null);
// Sync internalRef with forwarded ref
useEffect(() => {
if (!ref) return;
if (typeof ref === 'function') {
@@ -125,80 +114,12 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
return undefined;
}, [delay, isIntersecting]);
// Base typography styles mapping
const typeStyles: Record<TextType, Record<TextSize, string>> = {
display: {
l: 'font-serif text-[57px] leading-[64px] tracking-[-0.25px]',
m: 'font-serif text-[45px] leading-[52px] tracking-normal',
s: 'font-serif text-[36px] leading-[44px] tracking-normal',
},
headline: {
l: 'font-serif text-[32px] leading-[40px] tracking-normal',
m: 'font-serif text-[28px] leading-[36px] tracking-normal',
s: 'font-serif text-[24px] leading-[32px] tracking-normal',
},
title: {
l: 'font-sans text-[22px] leading-[28px] tracking-normal',
m: 'font-sans text-[16px] leading-[24px] tracking-[0.15px]',
s: 'font-sans text-[14px] leading-[20px] tracking-[0.1px]',
},
body: {
l: 'font-sans text-[16px] leading-[24px] tracking-[0.5px]',
m: 'font-sans text-[14px] leading-[20px] tracking-[0.25px]',
s: 'font-sans text-[12px] leading-[16px] tracking-[0.4px]',
},
label: {
l: 'font-sans font-medium text-[14px] leading-[20px] tracking-[0.1px]',
m: 'font-sans font-medium text-[12px] leading-[16px] tracking-[0.5px]',
s: 'font-sans font-medium text-[11px] leading-[16px] tracking-[0.5px]',
},
};
const colorClasses: Record<TextColor, string> = {
default: 'text-[var(--rb-text)]',
primary: 'text-[var(--rb-primary-text)]',
secondary: 'text-[var(--rb-secondary-text)]',
tertiary: 'text-[var(--rb-tertiary-text)]',
error: 'text-[var(--rb-error-text)]',
muted: 'text-[var(--rb-muted-text)]',
inherit: 'text-inherit',
};
const alignClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
};
const wrapClasses = {
nowrap: 'whitespace-nowrap',
balance: 'text-balance',
pretty: 'text-pretty',
};
let truncationClass = '';
if (maxLines) {
truncationClass = `line-clamp-${maxLines}`;
} else {
if (overflow === 'ellipsis') truncationClass = 'truncate';
if (overflow === 'clip') truncationClass = 'overflow-clip';
}
return (
<Component
{...props}
ref={internalRef}
className={cn(
typeStyles[type][size],
colorClasses[color],
bold && 'font-bold',
italic && 'italic',
underline && 'underline',
strike && 'line-through',
align && alignClasses[align],
wrap && wrapClasses[wrap],
truncationClass,
getTypographyClassNames({ type, size, color, bold, italic, underline, strike, align, wrap, overflow, maxLines }),
delay !== undefined &&
(isVisible
? 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out'