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

@@ -0,0 +1,134 @@
import { getTypographyClassNames } from './getTypographyClassNames';
describe('lib/typography/getTypographyClassNames', () => {
describe('type + size → @theme token class', () => {
it('returns text-rb-body-m by default', () => {
expect(getTypographyClassNames()).toContain('text-rb-body-m');
});
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: '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');
});
});
describe('font family', () => {
it('applies font-serif for display and headline', () => {
expect(getTypographyClassNames({ type: 'display' })).toContain('font-serif');
expect(getTypographyClassNames({ type: 'headline' })).toContain('font-serif');
});
it('applies font-sans for title, body, and label', () => {
expect(getTypographyClassNames({ type: 'title' })).toContain('font-sans');
expect(getTypographyClassNames({ type: 'body' })).toContain('font-sans');
expect(getTypographyClassNames({ type: 'label' })).toContain('font-sans');
});
});
describe('font weight', () => {
it('adds font-medium for label type', () => {
expect(getTypographyClassNames({ type: 'label' })).toContain('font-medium');
});
it('does not add font-medium for non-label types', () => {
expect(getTypographyClassNames({ type: 'body' })).not.toContain('font-medium');
expect(getTypographyClassNames({ type: 'title' })).not.toContain('font-medium');
});
it('adds font-bold when bold is true', () => {
expect(getTypographyClassNames({ bold: true })).toContain('font-bold');
});
it('does not add font-bold by default', () => {
expect(getTypographyClassNames()).not.toContain('font-bold');
});
});
describe('color', () => {
it('applies default color class', () => {
expect(getTypographyClassNames()).toContain('text-[var(--rb-text)]');
});
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: 'error' })).toContain('text-[var(--rb-error-text)]');
expect(getTypographyClassNames({ color: 'inherit' })).toContain('text-inherit');
});
});
describe('decorations', () => {
it('applies italic', () => {
expect(getTypographyClassNames({ italic: true })).toContain('italic');
});
it('applies underline', () => {
expect(getTypographyClassNames({ underline: true })).toContain('underline');
});
it('applies line-through for strike', () => {
expect(getTypographyClassNames({ strike: true })).toContain('line-through');
});
it('does not apply decorations by default', () => {
const result = getTypographyClassNames();
expect(result).not.toContain('italic');
expect(result).not.toContain('underline');
expect(result).not.toContain('line-through');
});
});
describe('alignment', () => {
it('applies text-center', () => {
expect(getTypographyClassNames({ align: 'center' })).toContain('text-center');
});
it('applies each alignment', () => {
expect(getTypographyClassNames({ align: 'left' })).toContain('text-left');
expect(getTypographyClassNames({ align: 'right' })).toContain('text-right');
expect(getTypographyClassNames({ align: 'justify' })).toContain('text-justify');
});
it('does not apply alignment by default', () => {
const result = getTypographyClassNames();
expect(result).not.toMatch(/text-(left|center|right|justify)/);
});
});
describe('wrap', () => {
it('applies whitespace-nowrap', () => {
expect(getTypographyClassNames({ wrap: 'nowrap' })).toContain('whitespace-nowrap');
});
it('applies text-balance', () => {
expect(getTypographyClassNames({ wrap: 'balance' })).toContain('text-balance');
});
it('applies text-pretty', () => {
expect(getTypographyClassNames({ wrap: 'pretty' })).toContain('text-pretty');
});
});
describe('overflow / maxLines', () => {
it('applies truncate for overflow ellipsis', () => {
expect(getTypographyClassNames({ overflow: 'ellipsis' })).toContain('truncate');
});
it('applies overflow-clip for overflow clip', () => {
expect(getTypographyClassNames({ overflow: 'clip' })).toContain('overflow-clip');
});
it('applies line-clamp for maxLines', () => {
expect(getTypographyClassNames({ maxLines: 3 })).toContain('line-clamp-3');
});
it('maxLines takes precedence over overflow', () => {
const result = getTypographyClassNames({ maxLines: 2, overflow: 'ellipsis' });
expect(result).toContain('line-clamp-2');
expect(result).not.toContain('truncate');
});
});
});
@@ -0,0 +1,158 @@
/**
* Typography type roles.
* Determines font family and base visual treatment.
*/
export type TextType = 'display' | 'headline' | 'title' | 'body' | 'label';
/**
* Typography size scale.
* Applied within a type role to select the appropriate @theme token.
*/
export type TextSize = 's' | 'm' | 'l';
/**
* Text color variants mapped to design-system CSS custom properties.
*/
export type TextColor =
| 'default'
| 'primary'
| 'secondary'
| 'tertiary'
| 'error'
| 'muted'
| 'inherit';
/**
* Text alignment.
*/
export type TextAlign = 'left' | 'center' | 'right' | 'justify';
/**
* Text wrapping behaviour.
*/
export type TextWrap = 'nowrap' | 'balance' | 'pretty';
/**
* Overflow handling for single-line text.
*/
export type TextOverflow = 'ellipsis' | 'clip';
/**
* Full set of typography options supported by getTypographyClassNames.
*/
export interface TypographyOptions {
/** Typography role — determines font family and base style. */
type?: TextType;
/** Size scale within the role. */
size?: TextSize;
/** Text color variant. */
color?: TextColor;
/** Bold weight. */
bold?: boolean;
/** Italic style. */
italic?: boolean;
/** Underline decoration. */
underline?: boolean;
/** Strikethrough decoration. */
strike?: boolean;
/** Text alignment. */
align?: TextAlign;
/** Text wrapping behaviour. */
wrap?: TextWrap;
/** Overflow truncation for single lines. */
overflow?: TextOverflow;
/** Maximum visible lines (truncates with ellipsis). Takes precedence over overflow. */
maxLines?: number;
}
const fontFamilyClasses: Record<TextType, string> = {
display: 'font-serif',
headline: 'font-serif',
title: 'font-sans',
body: 'font-sans',
label: 'font-sans',
};
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: Record<TextAlign, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
};
const wrapClasses: Record<TextWrap, string> = {
nowrap: 'whitespace-nowrap',
balance: 'text-balance',
pretty: 'text-pretty',
};
/**
* Returns a space-separated string of Tailwind CSS classes for the given
* typography options.
*
* The returned string is safe to pass directly to `cn()` alongside other
* classes — it contains no internal conflicts. Tailwind-merge is configured
* (via the stl `cn` utility) to treat `text-rb-{type}-{size}` as a
* font-size group so it never conflicts with text-color classes.
*
* @example
* ```tsx
* // In a component:
* <p className={cn(getTypographyClassNames({ type: 'body', size: 'l' }), className)}>
*
* // In a CSS-cascade parent/child pattern:
* <div className="rb-scale-l">
* <p className={cn(getTypographyClassNames({ type: 'body' }))}>…</p>
* </div>
* ```
*/
export function getTypographyClassNames(options: TypographyOptions = {}): string {
const {
type = 'body',
size = 'm',
color = 'default',
bold = false,
italic = false,
underline = false,
strike = false,
align,
wrap,
overflow,
maxLines,
} = options;
let truncationClass = '';
if (maxLines) {
truncationClass = `line-clamp-${maxLines}`;
} else if (overflow === 'ellipsis') {
truncationClass = 'truncate';
} else if (overflow === 'clip') {
truncationClass = 'overflow-clip';
}
return [
`text-rb-${type}-${size}`,
fontFamilyClasses[type],
type === 'label' && 'font-medium',
colorClasses[color],
bold && 'font-bold',
italic && 'italic',
underline && 'underline',
strike && 'line-through',
align && alignClasses[align],
wrap && wrapClasses[wrap],
truncationClass,
]
.filter(Boolean)
.join(' ');
}