fix: PageHead header alignment, analytics proxy, and infra improvements (#64)
🤖 Pull request created from `various-fixes` containing PageHead
breadcrumbs and navigation alignment fixes, Umami analytics proxy, and
infrastructure updates.
This commit is contained in:
1 parent
ff5a50bad8
commit
9ee672504f
110 files changed
+2649
-1760
No files matched your search
@@ -1,5 +1,3 @@
|
||||
import { createHighlighter } from 'shiki';
|
||||
|
||||
import type { Highlighter } from 'shiki';
|
||||
|
||||
/**
|
||||
@@ -77,14 +75,16 @@ class CodeHighlighter {
|
||||
return this.initializingPromise;
|
||||
}
|
||||
|
||||
this.initializingPromise = createHighlighter({
|
||||
themes: ['github-dark', 'github-light'],
|
||||
langs: SUPPORTED_LANGUAGES,
|
||||
}).then((highlighter) => {
|
||||
this.initializingPromise = (async () => {
|
||||
const { createHighlighter } = await import('shiki');
|
||||
const highlighter = await createHighlighter({
|
||||
themes: ['github-dark', 'github-light'],
|
||||
langs: SUPPORTED_LANGUAGES,
|
||||
});
|
||||
this.highlighter = highlighter;
|
||||
this.initializingPromise = null;
|
||||
return highlighter;
|
||||
});
|
||||
})();
|
||||
|
||||
return this.initializingPromise;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { Link } from './Link';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
import { render, screen, fireEvent } from '../../lib/testing';
|
||||
|
||||
describe('components/Link', () => {
|
||||
beforeEach(() => {
|
||||
@@ -66,6 +66,83 @@ describe('components/Link', () => {
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('opacity-0');
|
||||
expect(link).toHaveStyle({ animationDelay: '500ms', animationFillMode: 'both' });
|
||||
expect(link).toHaveClass('animate-in');
|
||||
});
|
||||
|
||||
it('obfuscates mailto link until user interaction', () => {
|
||||
render(
|
||||
<Link isObfuscated href="mailto:personal@ermnvldmr.com">
|
||||
personal@ermnvldmr.com
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'personal@ermnvldmr.com' });
|
||||
expect(link).toHaveAttribute('href', '#');
|
||||
expect(link).toHaveAttribute('data-user', 'personal');
|
||||
expect(link).toHaveAttribute('data-domain', 'ermnvldmr.com');
|
||||
|
||||
fireEvent.pointerEnter(link);
|
||||
expect(link).toHaveAttribute('href', 'mailto:personal@ermnvldmr.com');
|
||||
});
|
||||
|
||||
it('activates obfuscated mailto link on focus and pointer down', () => {
|
||||
const { rerender } = render(
|
||||
<Link isObfuscated href="mailto:focus@ermnvldmr.com">
|
||||
focus@ermnvldmr.com
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'focus@ermnvldmr.com' });
|
||||
fireEvent.focus(link);
|
||||
expect(link).toHaveAttribute('href', 'mailto:focus@ermnvldmr.com');
|
||||
|
||||
rerender(
|
||||
<Link key="pointer-down" isObfuscated href="mailto:pointer@ermnvldmr.com">
|
||||
pointer@ermnvldmr.com
|
||||
</Link>
|
||||
);
|
||||
const linkPointer = screen.getByRole('link', { name: 'pointer@ermnvldmr.com' });
|
||||
fireEvent.pointerDown(linkPointer);
|
||||
expect(linkPointer).toHaveAttribute('href', 'mailto:pointer@ermnvldmr.com');
|
||||
});
|
||||
|
||||
it('handles click on unactivated and activated obfuscated mailto link', () => {
|
||||
const mockLocation = { href: '' };
|
||||
vi.stubGlobal('location', mockLocation);
|
||||
|
||||
render(
|
||||
<Link isObfuscated href="mailto:click@ermnvldmr.com">
|
||||
click@ermnvldmr.com
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'click@ermnvldmr.com' });
|
||||
|
||||
fireEvent.click(link);
|
||||
expect(mockLocation.href).toBe('mailto:click@ermnvldmr.com');
|
||||
expect(link).toHaveAttribute('href', 'mailto:click@ermnvldmr.com');
|
||||
|
||||
// Click again when already activated
|
||||
fireEvent.click(link);
|
||||
expect(mockLocation.href).toBe('mailto:click@ermnvldmr.com');
|
||||
});
|
||||
|
||||
it('renders custom children directly when obfuscated instead of bidi override', () => {
|
||||
render(
|
||||
<Link isObfuscated href="mailto:custom@ermnvldmr.com">
|
||||
Send an Email
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'custom@ermnvldmr.com' });
|
||||
expect(link).toHaveTextContent('Send an Email');
|
||||
});
|
||||
|
||||
it('handles obfuscated mailto without at symbol gracefully', () => {
|
||||
render(
|
||||
<Link isObfuscated href="mailto:plainaddress">
|
||||
plainaddress
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'plainaddress' });
|
||||
expect(link).toHaveAttribute('data-user', '');
|
||||
expect(link).toHaveAttribute('data-domain', '');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { castRef, cn, filterDataAttributes } from '@ermnvldmr/stl';
|
||||
import React, { memo, useRef } from 'react';
|
||||
import React, { memo, useCallback, useRef, useState } from 'react';
|
||||
import { useLink } from 'react-aria';
|
||||
|
||||
import { Text } from '../Text/Text';
|
||||
@@ -27,6 +27,8 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps, DataAttribut
|
||||
color?: TextColor;
|
||||
/** Delay in milliseconds before the link appears after entering the viewport. */
|
||||
delay?: number;
|
||||
/** Whether to obfuscate the email/link from static HTML scrapers. */
|
||||
isObfuscated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +38,40 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps, DataAttribut
|
||||
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
|
||||
*/
|
||||
export const Link = memo(function Link(props: LinkProps) {
|
||||
const { children, className, href, isExternal, type, size, color, delay, ...otherProps } = props;
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
href,
|
||||
isExternal,
|
||||
type,
|
||||
size,
|
||||
color,
|
||||
delay,
|
||||
isObfuscated = false,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const [isActivated, setIsActivated] = useState(false);
|
||||
const isMailto = Boolean(isObfuscated && href?.startsWith('mailto:'));
|
||||
const rawEmail = isMailto && href ? href.slice(7) : '';
|
||||
const atIndex = rawEmail.indexOf('@');
|
||||
const user = atIndex !== -1 ? rawEmail.slice(0, atIndex) : '';
|
||||
const domain = atIndex !== -1 ? rawEmail.slice(atIndex + 1) : '';
|
||||
|
||||
const activate = useCallback(() => {
|
||||
setIsActivated(true);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
activate();
|
||||
if (!isActivated && href) {
|
||||
window.location.href = href;
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[activate, href, isActivated]
|
||||
);
|
||||
|
||||
// We use a specific ref type that useLink expects.
|
||||
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
|
||||
@@ -53,15 +88,35 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
const dataAttributes = filterDataAttributes(props);
|
||||
|
||||
if (href) {
|
||||
const resolvedHref = localePath(href);
|
||||
const isAutoExternal = isExternal ?? href.startsWith('http');
|
||||
const externalProps = isAutoExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
||||
|
||||
const resolvedHref = isMailto ? (isActivated ? href : '#') : localePath(href);
|
||||
|
||||
const isChildrenEmail = typeof children === 'string' && children === rawEmail;
|
||||
const displayedContent =
|
||||
isMailto && !isActivated && isChildrenEmail ? (
|
||||
<span style={{ unicodeBidi: 'bidi-override', direction: 'rtl' }}>
|
||||
{domain.split('').reverse().join('')}@{user.split('').reverse().join('')}
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
const obfuscationAttrs = isMailto
|
||||
? {
|
||||
'data-user': user,
|
||||
'data-domain': domain,
|
||||
'aria-label': rawEmail,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Text
|
||||
{...linkProps}
|
||||
{...externalProps}
|
||||
{...dataAttributes}
|
||||
{...obfuscationAttrs}
|
||||
ref={ref}
|
||||
as="a"
|
||||
className={sharedClasses}
|
||||
@@ -70,8 +125,12 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
href={resolvedHref}
|
||||
size={size}
|
||||
type={type}
|
||||
onClick={isMailto ? handleClick : undefined}
|
||||
onFocus={isMailto ? activate : undefined}
|
||||
onPointerDown={isMailto ? activate : undefined}
|
||||
onPointerEnter={isMailto ? activate : undefined}
|
||||
>
|
||||
{children}
|
||||
{displayedContent}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,14 @@ import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { Text } from './Text';
|
||||
import { render, screen, act } from '../../lib/testing';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -31,58 +26,25 @@ describe('components/Text', () => {
|
||||
expect(screen.getByText('Test content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is immediately visible when no delay is provided', () => {
|
||||
it('is immediately visible without animation 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');
|
||||
expect(el.style.animationDelay).toBe('');
|
||||
});
|
||||
|
||||
it('is initially hidden when delay is provided', () => {
|
||||
it('applies CSS animation classes and delay style when delay is provided', () => {
|
||||
render(<Text delay={500}>With delay</Text>);
|
||||
const el = screen.getByText('With delay');
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
expect(el).toHaveClass('animate-in', 'fade-in', 'slide-in-from-bottom-2');
|
||||
expect(el).toHaveStyle({ animationDelay: '500ms', animationFillMode: 'both' });
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('applies CSS animation classes and zero delay when 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');
|
||||
expect(el).toHaveStyle({ animationDelay: '0ms', animationFillMode: 'both' });
|
||||
});
|
||||
|
||||
it('applies alignment classes', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn, genericMemo, useIntersectionObserver, castMutableRef } from '@ermnvldmr/stl';
|
||||
import React, { forwardRef, useState, useEffect, useRef } from 'react';
|
||||
import { cn, genericMemo, castMutableRef } from '@ermnvldmr/stl';
|
||||
import React, { forwardRef, useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
getTypographyClassNames,
|
||||
@@ -85,7 +85,6 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const [isVisible, setIsVisible] = useState(delay === undefined);
|
||||
const internalRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,22 +96,13 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
}
|
||||
}, [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]);
|
||||
const delayStyle: React.CSSProperties | undefined =
|
||||
delay !== undefined
|
||||
? {
|
||||
animationDelay: `${delay}ms`,
|
||||
animationFillMode: 'both',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Component
|
||||
@@ -132,13 +122,14 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
overflow,
|
||||
maxLines,
|
||||
}),
|
||||
delay !== undefined &&
|
||||
(isVisible
|
||||
? 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out'
|
||||
: 'opacity-0'),
|
||||
delay !== undefined && 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out',
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
style={{
|
||||
...delayStyle,
|
||||
...props.style,
|
||||
}}
|
||||
type={htmlType ?? (Component === 'button' ? 'button' : undefined)}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in new issue
Block a user