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

+20 -1
View File
@@ -1,5 +1,20 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
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 twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': [{ 'text-rb': rbFontSizeClasses }],
},
},
});
/**
* Combines and merges CSS class names using clsx and tailwind-merge.
@@ -7,6 +22,10 @@ import { twMerge } from 'tailwind-merge';
* This utility function combines the power of clsx for conditional class handling
* with tailwind-merge for proper Tailwind CSS class deduplication and conflict resolution.
*
* Tailwind-merge is configured to recognise `text-rb-{type}-{size}` classes
* (e.g. `text-rb-body-m`) as font-size utilities so they do not conflict with
* text-color classes such as `text-[var(--rb-text)]`.
*
* @param inputs - Class values to combine (strings, objects, arrays, etc.)
* @returns Merged class string with conflicts resolved
*
@@ -39,9 +39,8 @@ export function useLocalStorage<T>(
const getSnapshot = useCallback(() => manager.get(), [manager]);
const value = useSyncExternalStore(subscribe, getSnapshot, () => {
// During SSR, we can't access localStorage.
// However, createLocalStorage is designed to throw if accessed during SSR.
// This hook is intended to be used in client-side components.
// SSR snapshot: createLocalStorage returns a no-op manager in Node context,
// so manager.get() safely returns defaultValue without accessing localStorage.
return manager.get();
});
@@ -44,9 +44,15 @@ describe('lib/window/localStorage', () => {
for (const key in mockStorage) delete mockStorage[key];
});
it('should throw if window is undefined', () => {
it('returns a no-op manager when window is undefined (SSR/SSG context)', () => {
vi.stubGlobal('window', undefined);
expect(() => createLocalStorage(KEY, DEFAULT_VALUE)).toThrow();
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
expect(manager.key).toBe(KEY);
expect(manager.get()).toEqual(DEFAULT_VALUE);
expect(() => manager.set({ foo: 'x' })).not.toThrow();
expect(() => manager.remove()).not.toThrow();
// get() still returns defaultValue after set (no-op in SSR)
expect(manager.get()).toEqual(DEFAULT_VALUE);
});
it('should get default value if empty', () => {
+8 -7
View File
@@ -17,7 +17,8 @@ export interface StorageManager<T> {
/**
* Creates a type-safe manager for a localStorage key.
*
* This utility is browser-only and will throw if accessed during SSR.
* In SSR/SSG context (no window), returns a no-op manager that always
* returns the default value. In the browser, uses localStorage as usual.
* Ensure you use a strict namespace for the key to avoid monorepo collisions.
* Recommended format: `ermnvldmr/{package}/{path}/{module}`
* Example: `ermnvldmr/ui/lib/theme`
@@ -31,15 +32,15 @@ export interface StorageManager<T> {
* const theme = createLocalStorage('ermnvldmr/ui/lib/theme', 'system');
* console.log(theme.get());
* ```
*
* @throws Error if window or localStorage is undefined.
*/
export function createLocalStorage<T>(key: string, defaultValue: T): StorageManager<T> {
if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
throw new Error(
`[createLocalStorage] Attempted to access storage for key "${key}" outside of a browser environment. ` +
'This utility is strictly for client-side use.'
);
return {
key,
get: () => defaultValue,
set: () => { /* no-op in SSR/SSG context */ },
remove: () => { /* no-op in SSR/SSG context */ },
};
}
const manager: StorageManager<T> = {