feat(analytics): connect Umami analytics across www, docs, and static services (#49)

## Summary of Changes

- **`@ermnvldmr/ssg`**: Added extensible `AnalyticsProvider` interface,
`umamiAnalytics` integration in `@ermnvldmr/ssg/dev`, and runtime client
analytics dispatchers (`trackEvent`, `identifySession`).
- **`services/docs`**: Configured native Hextra Umami parameters in
`hugo.yaml` (`docs.ermnvldmr.com` with UUID
`fbdadf3f-3484-4186-a2c2-cedcf76cf44f`).
- **`services/www`**: Injected Umami tracking provider into
`rsbuild.config.ts` (`ermnvldmr.com`, `www.ermnvldmr.com` with UUID
`4fca5ac2-4fe4-4626-b708-933c37bfe918`).
- **`services/static`**: Injected Umami tracking provider into
`rsbuild.config.ts` (`static.ermnvldmr.com` with UUID
`0831532a-2cec-4955-8f5b-452d8bba96d8`).
- **Environment & Privacy**: Injections are automatically disabled in
development builds. Configured `data-domains` filtering and
`data-do-not-track="true"`.

## Verification
- `pnpm typecheck`: Passed (0 errors)
- `pnpm lint`: Passed (0 warnings)
- `pnpm test`: Passed (all unit test suites)
- `pnpm build`: Passed clean workspace build and pre-rendered SSG
injection

---
Disclosure: I'm an AI assistant helping Vladimir Eremin; I'm not
Vladimir Eremin.
This commit is contained in:
deytenit authored and GitHub committed 2026-08-23 20:28:22 +03:00
1 parent c8beec4d47
commit ea805c724b
31 files changed
+622 -60

No files matched your search

+7 -3
View File
@@ -1,9 +1,9 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import { cn, castRef, filterDataAttributes, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef, useRef } from 'react';
import { useButton, useLink, useFocusRing, useHover } from 'react-aria';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { ClassNameProps, DataAttributes, TestIdProps } from '@ermnvldmr/stl';
import type { AriaButtonProps, AriaLinkOptions } from 'react-aria';
/**
@@ -34,7 +34,7 @@ export type ButtonSize = 's' | 'm' | 'l';
/**
* Props shared by both action and link button variants.
*/
interface BaseButtonProps extends ClassNameProps, TestIdProps {
interface BaseButtonProps extends ClassNameProps, TestIdProps, DataAttributes {
/** Content to be rendered inside the button */
children?: React.ReactNode;
/** Visual style variant */
@@ -260,12 +260,15 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
</>
);
const dataAttributes = filterDataAttributes(props);
if (href) {
return (
<a
{...linkProps}
{...focusProps}
{...hoverProps}
{...dataAttributes}
ref={castRef<HTMLAnchorElement>(targetRef)}
className={sharedClasses}
data-testid={testId}
@@ -280,6 +283,7 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
{...buttonProps}
{...focusProps}
{...hoverProps}
{...dataAttributes}
ref={castRef<HTMLButtonElement>(targetRef)}
className={sharedClasses}
data-testid={testId}
@@ -1,10 +1,10 @@
import { castRef, cn, genericMemo } from '@ermnvldmr/stl';
import { castRef, cn, filterDataAttributes, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef } from 'react';
import { usePress } from 'react-aria';
import type { SpacingScale } from '../../lib/scale';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { ClassNameProps, DataAttributes, TestIdProps } from '@ermnvldmr/stl';
import type { PressEvents } from 'react-aria';
/**
@@ -32,7 +32,7 @@ export type ContainerRounded = 'none' | 'sm' | 'md' | 'lg' | 'full';
/**
* Props for the Container component.
*/
export interface ContainerProps extends ClassNameProps, TestIdProps {
export interface ContainerProps extends ClassNameProps, TestIdProps, DataAttributes {
/** Content to be rendered inside the container */
children: React.ReactNode;
/** Background color variant */
@@ -153,7 +153,10 @@ const containerVariants = cva('overflow-hidden', {
* borders, and width constraints while maintaining theme consistency.
*/
const ContainerComponent = forwardRef<HTMLElement, ContainerProps>(function Container(
{
props,
ref
) {
const {
children,
bg = 'base',
padding = 0,
@@ -166,9 +169,8 @@ const ContainerComponent = forwardRef<HTMLElement, ContainerProps>(function Cont
as: Component = 'div',
className,
'data-testid': testId,
},
ref
) {
} = props;
if (process.env.NODE_ENV !== 'production' && onPress && href) {
console.warn('[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.');
}
@@ -183,10 +185,12 @@ const ContainerComponent = forwardRef<HTMLElement, ContainerProps>(function Cont
const interactionProps = isPressable ? pressProps : {};
const FinalComponent = isLink ? 'a' : Component;
const dataAttributes = filterDataAttributes(props);
return (
<FinalComponent
{...interactionProps}
{...dataAttributes}
ref={castRef<HTMLElement>(ref)}
className={cn(
containerVariants({
+7 -3
View File
@@ -1,18 +1,18 @@
import { localePath } from '@ermnvldmr/i18n';
import { castRef, cn } from '@ermnvldmr/stl';
import { castRef, cn, filterDataAttributes } from '@ermnvldmr/stl';
import React, { memo, useRef } from 'react';
import { useLink } from 'react-aria';
import { Text } from '../Text/Text';
import type { TextColor, TextSize, TextType } from '../Text/Text';
import type { ClassNameProps } from '@ermnvldmr/stl';
import type { ClassNameProps, DataAttributes } from '@ermnvldmr/stl';
import type { AriaLinkOptions } from 'react-aria';
/**
* Props for the Link component.
*/
export interface LinkProps extends AriaLinkOptions, ClassNameProps {
export interface LinkProps extends AriaLinkOptions, ClassNameProps, DataAttributes {
/** The destination URL. Renders as an <a> tag if provided. */
href?: string;
/** Link content */
@@ -50,6 +50,8 @@ export const Link = memo(function Link(props: LinkProps) {
className
);
const dataAttributes = filterDataAttributes(props);
if (href) {
const resolvedHref = localePath(href);
const isAutoExternal = isExternal ?? href.startsWith('http');
@@ -59,6 +61,7 @@ export const Link = memo(function Link(props: LinkProps) {
<Text
{...linkProps}
{...externalProps}
{...dataAttributes}
ref={ref}
as="a"
className={sharedClasses}
@@ -76,6 +79,7 @@ export const Link = memo(function Link(props: LinkProps) {
return (
<Text
{...linkProps}
{...dataAttributes}
ref={castRef<HTMLSpanElement>(ref)}
as="span"
className={sharedClasses}
@@ -0,0 +1,30 @@
import { createContext, useContext } from 'react';
/**
* Function signature for telemetry and event logging.
*/
export type LogFn = (event: string, data?: Record<string, string | number | boolean>) => void;
const noopLogger: LogFn = () => undefined;
/**
* React context holding the active logging function.
*/
export const LogContext = createContext<LogFn>(noopLogger);
/**
* Hook to access the logging function from the nearest LogProvider.
*
* Defaults to a safe no-op if rendered outside a LogProvider.
*
* @returns The active logging function.
*
* @example
* ```tsx
* const log = useLogger();
* log('button-click', { buttonId: 'submit' });
* ```
*/
export function useLogger(): LogFn {
return useContext(LogContext);
}
@@ -0,0 +1,42 @@
import React from 'react';
import { useLogger } from './LogContext';
import { LogProvider } from './LogProvider';
import { Button } from '../Button/Button';
import type { Meta, StoryObj } from '@storybook/react';
function LogDemo(): React.JSX.Element {
const log = useLogger();
return (
<Button
variant="solid"
onPress={() => {
log('story-click', { timestamp: Date.now() });
}}
>
Click to Log Event
</Button>
);
}
const meta: Meta<typeof LogProvider> = {
title: 'Components/LogProvider',
component: LogProvider,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof LogProvider>;
export const Default: Story = {
render: () => (
<LogProvider
logger={(event, data) => {
console.log(`[Event: ${event}]`, data);
}}
>
<LogDemo />
</LogProvider>
),
};
@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { useLogger } from './LogContext';
import { LogProvider } from './LogProvider';
function ConsumerComponent(): React.JSX.Element {
const log = useLogger();
return (
<button type="button" onClick={() => log('test-event', { key: 'value' })}>
Trigger Log
</button>
);
}
describe('LogProvider', () => {
it('defaults to no-op when outside LogProvider', () => {
render(<ConsumerComponent />);
const button = screen.getByRole('button', { name: 'Trigger Log' });
expect(() => button.click()).not.toThrow();
});
it('calls the provided logger on event dispatch', () => {
const loggerMock = vi.fn();
render(
<LogProvider logger={loggerMock}>
<ConsumerComponent />
</LogProvider>
);
const button = screen.getByRole('button', { name: 'Trigger Log' });
button.click();
expect(loggerMock).toHaveBeenCalledWith('test-event', { key: 'value' });
});
});
@@ -0,0 +1,34 @@
import React, { useMemo } from 'react';
import { LogContext } from './LogContext';
import type { LogFn } from './LogContext';
/**
* Props for the LogProvider component.
*/
export interface LogProviderProps {
/** The telemetry logging function to inject into the React tree. */
logger?: LogFn;
/** Children components that will have access to the logger via useLogger. */
children: React.ReactNode;
}
/**
* Provider component for injecting analytics and telemetry loggers into the UI component tree.
*
* @param props - Component props containing optional logger and children.
* @returns React context provider element.
*
* @example
* ```tsx
* <LogProvider logger={trackEvent}>
* <App />
* </LogProvider>
* ```
*/
export function LogProvider({ logger, children }: LogProviderProps): React.JSX.Element {
const value: LogFn = useMemo(() => logger ?? (() => undefined), [logger]);
return <LogContext.Provider value={value}>{children}</LogContext.Provider>;
}
@@ -0,0 +1,2 @@
export * from './LogContext';
export * from './LogProvider';
+12 -7
View File
@@ -1,9 +1,9 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import { cn, castRef, filterDataAttributes, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef } from 'react';
import type { SpacingScale } from '../../lib/scale';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { ClassNameProps, DataAttributes, TestIdProps } from '@ermnvldmr/stl';
/**
* Flexbox direction values.
@@ -28,7 +28,7 @@ type FlexWrap = 'nowrap' | 'wrap' | 'wrap-reverse';
/**
* Props for the flexible Stack layout component.
*/
export interface StackProps extends ClassNameProps, TestIdProps {
export interface StackProps extends ClassNameProps, TestIdProps, DataAttributes {
/** Content to be arranged in the stack */
children: React.ReactNode;
/** Flexbox direction (row, column, or their reverse variants) */
@@ -165,7 +165,10 @@ const stackVariants = cva('flex', {
* ```
*/
const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack(
{
props,
ref
) {
const {
children,
direction = 'row',
justify = 'start',
@@ -176,11 +179,13 @@ const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack(
as: Component = 'div',
className,
'data-testid': testId,
},
ref
) {
} = props;
const dataAttributes = filterDataAttributes(props);
return (
<Component
{...dataAttributes}
ref={castRef<HTMLElement>(ref)}
className={cn(
stackVariants({ direction, justify, align, wrap, gap, scrollable }),
+5 -2
View File
@@ -1,4 +1,4 @@
import { cn } from '@ermnvldmr/stl';
import { cn, filterDataAttributes } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { useRef } from 'react';
import { useSwitch, useFocusRing, VisuallyHidden } from 'react-aria';
@@ -6,6 +6,7 @@ import { useToggleState } from 'react-stately';
import { Text } from '../Text/Text';
import type { ClassNameProps, DataAttributes } from '@ermnvldmr/stl';
import type { AriaSwitchProps } from 'react-aria';
/**
@@ -31,7 +32,7 @@ export type SwitchColor =
/**
* Props for the Switch component.
*/
export interface SwitchProps extends AriaSwitchProps {
export interface SwitchProps extends AriaSwitchProps, ClassNameProps, DataAttributes {
/** Optional class name for the outer container */
className?: string;
/** Optional label for the switch */
@@ -220,9 +221,11 @@ export function Switch(props: SwitchProps): React.JSX.Element {
const ref = useRef<HTMLInputElement>(null);
const { inputProps } = useSwitch(props, state, ref);
const { isFocusVisible, focusProps } = useFocusRing();
const dataAttributes = filterDataAttributes(props);
return (
<label
{...dataAttributes}
className={cn(
'group inline-flex items-center cursor-pointer select-none gap-3',
props.isDisabled && 'opacity-50 cursor-not-allowed',
+1
View File
@@ -10,6 +10,7 @@ export * from './Icon/index';
export * from './Image/Image';
export * from './Link/Link';
export * from './List/List';
export * from './LogProvider/index';
export * from './Paragraph/Paragraph';
export * from './Separator/Separator';
export * from './Stack/Stack';