Compare commits

..
3 Commits
23 changed files with 274 additions and 53 deletions

No files matched your search

+1 -1
View File
@@ -6,4 +6,4 @@ resources:
- service.yaml - service.yaml
images: images:
- name: ghcr.io/deytenit/ermnvldmr.com/docs - name: ghcr.io/deytenit/ermnvldmr.com/docs
newTag: sha-0e77ea1 newTag: sha-9200529
+1 -1
View File
@@ -6,4 +6,4 @@ metadata:
data: data:
DATABASE_TYPE: "postgresql" DATABASE_TYPE: "postgresql"
CLIENT_IP_HEADER: "X-Forwarded-For" CLIENT_IP_HEADER: "X-Forwarded-For"
TRACKER_SCRIPT_NAME: "beacon.js" TRACKER_SCRIPT_NAME: "trace.js"
+1 -1
View File
@@ -18,7 +18,7 @@ spec:
labels: labels:
app: umami app: umami
annotations: annotations:
config/tracker-script-name: "beacon.js" config/tracker-script-name: "trace.js"
spec: spec:
containers: containers:
- name: umami - name: umami
+1 -1
View File
@@ -6,4 +6,4 @@ resources:
- service.yaml - service.yaml
images: images:
- name: ghcr.io/deytenit/ermnvldmr.com/www - name: ghcr.io/deytenit/ermnvldmr.com/www
newTag: sha-24f3311 newTag: sha-9200529
@@ -20,7 +20,7 @@ spec:
- host: www.ermnvldmr.com - host: www.ermnvldmr.com
http: http:
paths: paths:
- path: /beacon - path: /trace
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
@@ -30,7 +30,7 @@ spec:
- host: docs.ermnvldmr.com - host: docs.ermnvldmr.com
http: http:
paths: paths:
- path: /beacon - path: /trace
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
@@ -6,4 +6,4 @@ metadata:
spec: spec:
stripPrefix: stripPrefix:
prefixes: prefixes:
- /beacon - /trace
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { umamiAnalytics } from './umami';
describe('umamiAnalytics', () => {
it('suppresses script injection in non-production environments when not forced', () => {
const provider = umamiAnalytics({
serverUrl: '/pulse',
websiteId: 'test-website-id',
});
expect(provider.getHtmlTags(false)).toEqual([]);
});
it('injects script tag in non-production when force is true', () => {
const provider = umamiAnalytics({
serverUrl: '/pulse',
websiteId: 'test-website-id',
force: true,
});
const tags = provider.getHtmlTags(false);
expect(tags).toHaveLength(1);
expect(tags[0]?.attrs?.src).toBe('/pulse/script.js');
});
it('generates expected script attributes in production builds', () => {
const provider = umamiAnalytics({
serverUrl: '/trace/',
scriptName: 'trace.js',
hostUrl: '/trace',
websiteId: '41c7699a-0675-4f49-bcd0-3df47a66b6b1',
domains: 'ermnvldmr.com,www.ermnvldmr.com',
doNotTrack: true,
tag: 'landing',
});
const tags = provider.getHtmlTags(true);
expect(tags).toEqual([
{
tag: 'script',
head: true,
publicPath: false,
attrs: {
defer: true,
src: '/trace/trace.js',
'data-website-id': '41c7699a-0675-4f49-bcd0-3df47a66b6b1',
'data-host-url': '/trace',
'data-domains': 'ermnvldmr.com,www.ermnvldmr.com',
'data-do-not-track': 'true',
'data-tag': 'landing',
},
},
]);
});
});
@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Switch } from './Switch'; import { Switch } from './Switch';
import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
@@ -8,6 +9,16 @@ const meta: Meta<typeof Switch> = {
title: 'Components/Switch', title: 'Components/Switch',
component: Switch, component: Switch,
tags: ['autodocs'], tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['s', 'm', 'l'],
},
variant: {
control: 'select',
options: ['solid', 'outline', 'ghost'],
},
},
}; };
export default meta; export default meta;
@@ -19,6 +30,16 @@ export const Default: Story = {
}, },
}; };
export const Sizes: Story = {
render: () => (
<VStack gap={4}>
<Switch size="s">Small Switch</Switch>
<Switch size="m">Medium Switch</Switch>
<Switch size="l">Large Switch</Switch>
</VStack>
),
};
export const Selected: Story = { export const Selected: Story = {
args: { args: {
children: 'Selected State', children: 'Selected State',
@@ -66,5 +66,18 @@ describe('components/Switch', () => {
expect(track).toHaveClass('data-[selected=true]:bg-primary'); expect(track).toHaveClass('data-[selected=true]:bg-primary');
expect(thumb).toHaveClass('data-[selected=true]:bg-primary-text'); expect(thumb).toHaveClass('data-[selected=true]:bg-primary-text');
}); });
it('applies size variant classes to track and thumb', () => {
const { container, rerender } = render(<Switch size="s">Small</Switch>);
const track = container.querySelector('[aria-hidden="true"]');
const thumb = track?.firstElementChild;
expect(track).toHaveClass('w-7', 'h-4');
expect(thumb).toHaveClass('w-2.5', 'h-2.5');
rerender(<Switch size="l">Large</Switch>);
expect(track).toHaveClass('w-14', 'h-8');
expect(thumb).toHaveClass('w-6', 'h-6');
});
}); });
+39 -9
View File
@@ -14,6 +14,11 @@ import type { AriaSwitchProps } from 'react-aria';
*/ */
export type SwitchVariant = 'solid' | 'outline' | 'ghost'; export type SwitchVariant = 'solid' | 'outline' | 'ghost';
/**
* Switch size variants.
*/
export type SwitchSize = 's' | 'm' | 'l';
/** /**
* Switch color themes. * Switch color themes.
*/ */
@@ -39,12 +44,14 @@ export interface SwitchProps extends AriaSwitchProps, ClassNameProps, DataAttrib
children?: React.ReactNode; children?: React.ReactNode;
/** Visual style variant */ /** Visual style variant */
variant?: SwitchVariant; variant?: SwitchVariant;
/** Switch size */
size?: SwitchSize;
/** Color theme */ /** Color theme */
color?: SwitchColor; color?: SwitchColor;
} }
const trackVariants = cva( const trackVariants = cva(
'relative w-11 h-6 transition-all duration-200 ease-in-out rounded-full outline-none flex items-center', 'relative transition-all duration-200 ease-in-out rounded-full outline-none flex items-center',
{ {
variants: { variants: {
variant: { variant: {
@@ -52,6 +59,11 @@ const trackVariants = cva(
outline: 'border-2 data-[selected=false]:bg-transparent data-[selected=false]:border-muted', outline: 'border-2 data-[selected=false]:bg-transparent data-[selected=false]:border-muted',
ghost: 'data-[selected=false]:bg-transparent data-[selected=false]:border-transparent', ghost: 'data-[selected=false]:bg-transparent data-[selected=false]:border-transparent',
}, },
size: {
s: 'w-7 h-4',
m: 'w-11 h-6',
l: 'w-14 h-8',
},
color: { color: {
primary: '', primary: '',
'primary-negative': '', 'primary-negative': '',
@@ -118,19 +130,25 @@ const trackVariants = cva(
], ],
defaultVariants: { defaultVariants: {
variant: 'solid', variant: 'solid',
size: 'm',
color: 'primary', color: 'primary',
}, },
} }
); );
const thumbVariants = cva( const thumbVariants = cva(
'w-4 h-4 transition-all duration-200 ease-in-out transform rounded-full shadow-sm data-[selected=false]:bg-[var(--rb-text)] data-[selected=false]:translate-x-0', 'transition-all duration-200 ease-in-out transform rounded-full shadow-sm data-[selected=false]:bg-[var(--rb-text)] data-[selected=false]:translate-x-0',
{ {
variants: { variants: {
variant: { variant: {
solid: 'ml-1 data-[selected=true]:translate-x-5', solid: '',
outline: 'ml-0.5 data-[selected=true]:translate-x-4.5', outline: '',
ghost: 'ml-1 data-[selected=true]:translate-x-5', ghost: '',
},
size: {
s: 'w-2.5 h-2.5',
m: 'w-4 h-4',
l: 'w-6 h-6',
}, },
color: { color: {
primary: '', primary: '',
@@ -146,6 +164,17 @@ const thumbVariants = cva(
}, },
}, },
compoundVariants: [ compoundVariants: [
// size + variant offsets
{ size: 's', variant: 'solid', className: 'ml-0.5 data-[selected=true]:translate-x-3.5' },
{ size: 's', variant: 'outline', className: 'ml-0.5 data-[selected=true]:translate-x-2.5' },
{ size: 's', variant: 'ghost', className: 'ml-0.5 data-[selected=true]:translate-x-3.5' },
{ size: 'm', variant: 'solid', className: 'ml-1 data-[selected=true]:translate-x-5' },
{ size: 'm', variant: 'outline', className: 'ml-0.5 data-[selected=true]:translate-x-4.5' },
{ size: 'm', variant: 'ghost', className: 'ml-1 data-[selected=true]:translate-x-5' },
{ size: 'l', variant: 'solid', className: 'ml-1 data-[selected=true]:translate-x-6' },
{ size: 'l', variant: 'outline', className: 'ml-0.5 data-[selected=true]:translate-x-5.5' },
{ size: 'l', variant: 'ghost', className: 'ml-1 data-[selected=true]:translate-x-6' },
// primary // primary
{ variant: 'solid', color: 'primary', className: 'data-[selected=true]:bg-primary-text' }, { variant: 'solid', color: 'primary', className: 'data-[selected=true]:bg-primary-text' },
{ variant: 'outline', color: 'primary', className: 'data-[selected=true]:bg-primary-text' }, { variant: 'outline', color: 'primary', className: 'data-[selected=true]:bg-primary-text' },
@@ -198,6 +227,7 @@ const thumbVariants = cva(
], ],
defaultVariants: { defaultVariants: {
variant: 'solid', variant: 'solid',
size: 'm',
color: 'primary', color: 'primary',
}, },
} }
@@ -216,7 +246,7 @@ const thumbVariants = cva(
* ``` * ```
*/ */
export function Switch(props: SwitchProps): React.JSX.Element { export function Switch(props: SwitchProps): React.JSX.Element {
const { children, className, variant = 'solid', color = 'primary' } = props; const { children, className, variant = 'solid', size = 'm', color = 'primary' } = props;
const state = useToggleState(props); const state = useToggleState(props);
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
const { inputProps } = useSwitch(props, state, ref); const { inputProps } = useSwitch(props, state, ref);
@@ -238,14 +268,14 @@ export function Switch(props: SwitchProps): React.JSX.Element {
<div <div
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
trackVariants({ variant, color }), trackVariants({ variant, size, color }),
isFocusVisible && 'ring-2 ring-offset-2 ring-[var(--rb-ring)]', isFocusVisible && 'ring-2 ring-offset-2 ring-[var(--rb-ring)]',
props.isDisabled && 'grayscale-[0.5]' props.isDisabled && 'grayscale-[0.5]'
)} )}
data-selected={state.isSelected} data-selected={state.isSelected}
> >
<div <div
className={thumbVariants({ variant, color })} className={thumbVariants({ variant, size, color })}
data-selected={state.isSelected} data-selected={state.isSelected}
/> />
</div> </div>
@@ -253,7 +283,7 @@ export function Switch(props: SwitchProps): React.JSX.Element {
<Text <Text
bold={state.isSelected} bold={state.isSelected}
color={state.isSelected ? 'default' : 'muted'} color={state.isSelected ? 'default' : 'muted'}
size="s" size={size}
type="label" type="label"
> >
{children} {children}
+2 -2
View File
@@ -6,7 +6,7 @@ export default defineCli({
name: 'sync:static', name: 'sync:static',
description: 'Sync static assets to S3', description: 'Sync static assets to S3',
targetPrefix: 'docs', targetPrefix: 'docs',
sourceDir: './static', sourceDir: './public/static',
sourceFilesPattern: 'content/**/*.{md,html}', sourceFilesPattern: 'content/**/*.{md,html}',
urlPrefixes: ['/static/', '/images/'], urlPrefixes: ['/static/', '/images/'],
bucket: env('COM_ERMNVLDMR_STATIC_S3_BUCKET'), bucket: env('COM_ERMNVLDMR_STATIC_S3_BUCKET'),
@@ -16,7 +16,7 @@ export default defineCli({
accessKeyId: env('COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID'), accessKeyId: env('COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID'),
secretAccessKey: env('COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY'), secretAccessKey: env('COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY'),
hooks: { hooks: {
beforeSync: 'bash ../../ci/scripts/optimize-images.sh ./static', beforeSync: 'bash ../../ci/scripts/optimize-images.sh ./public/static',
}, },
}), }),
], ],
+36
View File
@@ -0,0 +1,36 @@
(function () {
var KEY = "umami.disabled";
var toggles = document.querySelectorAll("[data-umami-toggle]");
if (!toggles.length) return;
function sync() {
var enabled = true;
try {
enabled = window.localStorage.getItem(KEY) !== "1";
} catch (e) {}
toggles.forEach(function (el) {
el.checked = enabled;
});
}
sync();
toggles.forEach(function (el) {
el.addEventListener("change", function () {
try {
if (el.checked) {
window.localStorage.removeItem(KEY);
} else {
window.localStorage.setItem(KEY, "1");
}
} catch (e) {}
sync();
});
});
window.addEventListener("storage", function (e) {
if (e.key === KEY || e.key === null) {
sync();
}
});
})();
+4 -4
View File
@@ -48,10 +48,10 @@ params:
# Analytics configuration # Analytics configuration
analytics: analytics:
umami: umami:
serverURL: '/beacon' serverURL: '/trace'
scriptName: 'beacon.js' scriptName: 'trace.js'
hostURL: '/beacon' hostURL: '/trace'
websiteID: 'fbdadf3f-3484-4186-a2c2-cedcf76cf44f' websiteID: '6e65136b-48c2-4519-816f-023932ceb775'
domains: 'docs.ermnvldmr.com' domains: 'docs.ermnvldmr.com'
doNotTrack: 'true' doNotTrack: 'true'
+1 -1
View File
@@ -1 +1 @@
copyright: '© 2025 Eremin Vladimir. CC-BY-4.0. All rights reserved.' copyright: '© 2026 Vladimir Eremin · <a class="hx:underline hx:decoration-from-font hx:underline-offset-2 hx:hover:text-gray-900 hx:dark:hover:text-gray-100" href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC-BY 4.0</a><br/><span class="hx:inline-flex hx:items-center hx:flex-wrap hx:gap-1.5 hx:mt-1 hx:text-[11px] hx:text-gray-400 hx:dark:text-neutral-500">To make this site better, I collect anonymous analytics <label class="hx:inline-flex hx:items-center hx:cursor-pointer hx:align-middle"><input type="checkbox" data-umami-toggle aria-label="Toggle anonymous telemetry" checked class="hx:cursor-pointer hx:h-3.5 hx:w-3.5" /></label> · <a class="hx:underline hx:decoration-from-font hx:underline-offset-2 hx:hover:text-gray-600 hx:dark:hover:text-neutral-400" href="https://static.ermnvldmr.com/docs/privacy-policy.txt" target="_blank" rel="noopener noreferrer">Privacy Policy</a></span>'
+1 -1
View File
@@ -1,4 +1,4 @@
copyright: '© 2025 Ерёмин Владимир. CC-BY-4.0. Все права защищены.' copyright: '© 2026 Владимир Ерёмин · <a class="hx:underline hx:decoration-from-font hx:underline-offset-2 hx:hover:text-gray-900 hx:dark:hover:text-gray-100" href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC-BY 4.0</a><br/><span class="hx:inline-flex hx:items-center hx:flex-wrap hx:gap-1.5 hx:mt-1 hx:text-[11px] hx:text-gray-400 hx:dark:text-neutral-500">Из желания сделать этот сайт лучше — я собираю анонимную аналитику <label class="hx:inline-flex hx:items-center hx:cursor-pointer hx:align-middle"><input type="checkbox" data-umami-toggle aria-label="Переключить анонимную телеметрию" checked class="hx:cursor-pointer hx:h-3.5 hx:w-3.5" /></label> · <a class="hx:underline hx:decoration-from-font hx:underline-offset-2 hx:hover:text-gray-600 hx:dark:hover:text-neutral-400" href="https://static.ermnvldmr.com/docs/privacy-policy.txt" target="_blank" rel="noopener noreferrer">Политика конфиденциальности</a></span>'
note: Кстати note: Кстати
tip: Полезно tip: Полезно
important: Важно important: Важно
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

+2 -1
View File
@@ -2,13 +2,14 @@ import { localDevServer } from '@ermnvldmr/rsbuild-config/dev';
import { defineSSGServiceConfig, discoverEntries, umamiAnalytics } from '@ermnvldmr/ssg/dev'; import { defineSSGServiceConfig, discoverEntries, umamiAnalytics } from '@ermnvldmr/ssg/dev';
import { THEME_INIT_SCRIPT, THEME_INIT_STYLES } from '@ermnvldmr/ui/dev'; import { THEME_INIT_SCRIPT, THEME_INIT_STYLES } from '@ermnvldmr/ui/dev';
const UMAMI_STATIC_WEBSITE_ID = '0831532a-2cec-4955-8f5b-452d8bba96d8'; const UMAMI_STATIC_WEBSITE_ID = 'de274e99-9e1e-41bb-981a-9a4fd21d39c5';
export default defineSSGServiceConfig({ export default defineSSGServiceConfig({
server: localDevServer({ port: 3001 }), server: localDevServer({ port: 3001 }),
analytics: [ analytics: [
umamiAnalytics({ umamiAnalytics({
serverUrl: 'https://umami.ermnvldmr.com', serverUrl: 'https://umami.ermnvldmr.com',
scriptName: 'trace.js',
websiteId: UMAMI_STATIC_WEBSITE_ID, websiteId: UMAMI_STATIC_WEBSITE_ID,
domains: 'static.ermnvldmr.com', domains: 'static.ermnvldmr.com',
doNotTrack: true, doNotTrack: true,
+5 -5
View File
@@ -33,18 +33,18 @@ const MIME: Record<string, string> = {
'.txt': 'text/plain', '.txt': 'text/plain',
}; };
const UMAMI_WWW_WEBSITE_ID = '4fca5ac2-4fe4-4626-b708-933c37bfe918'; const UMAMI_WWW_WEBSITE_ID = '41c7699a-0675-4f49-bcd0-3df47a66b6b1';
export default defineSSGServiceConfig( export default defineSSGServiceConfig(
mergeConfig(localeRsbuildConfig(), { mergeConfig(localeRsbuildConfig(), {
server: localDevServer({ port: 3000 }), server: localDevServer({ port: 3000 }),
analytics: [ analytics: [
umamiAnalytics({ umamiAnalytics({
serverUrl: '/beacon', serverUrl: '/trace',
scriptName: 'beacon.js', scriptName: 'trace.js',
hostUrl: '/beacon', hostUrl: '/trace',
websiteId: UMAMI_WWW_WEBSITE_ID, websiteId: UMAMI_WWW_WEBSITE_ID,
domains: 'ermnvldmr.com,www.ermnvldmr.com', domains: 'www.ermnvldmr.com',
doNotTrack: true, doNotTrack: true,
}), }),
], ],
@@ -1,5 +1,14 @@
import { createKeyset } from '@ermnvldmr/i18n'; import { createKeyset } from '@ermnvldmr/i18n';
export const { t } = createKeyset({ export const { t, tRaw } = createKeyset({
'Vladimir Eremin': { en: 'Vladimir Eremin', ru: 'Владимир Ерёмин' }, 'Vladimir Eremin': { en: 'Vladimir Eremin', ru: 'Владимир Ерёмин' },
'Privacy Policy': { en: 'Privacy Policy', ru: 'Политика конфиденциальности' },
'Toggle anonymous telemetry': {
en: 'Toggle anonymous telemetry',
ru: 'Переключить анонимную телеметрию',
},
'To make this site better, I collect anonymous analytics · {privacyLink}': {
en: 'To\u00A0make this site better, I\u00A0collect anonymous analytics · {privacyLink}',
ru: 'Из\u00A0желания сделать этот сайт лучше\u00A0— я\u00A0собираю анонимную аналитику · {privacyLink}',
},
}); });
+55 -20
View File
@@ -1,11 +1,14 @@
import { ThemeSwitch } from '@ermnvldmr/kits'; import { ThemeSwitch } from '@ermnvldmr/kits';
import { Container, HStack, Icon, Link, Text, VStack } from '@ermnvldmr/ui'; import { createLocalStorage, useLocalStorage } from '@ermnvldmr/stl';
import { Container, HStack, Icon, Link, Switch, Text, VStack } from '@ermnvldmr/ui';
import { Monitor, Moon, Sun } from 'lucide-react'; import { Monitor, Moon, Sun } from 'lucide-react';
import React from 'react'; import React, { useCallback } from 'react';
import { LocaleSwitch } from '../LocaleSwitch/LocaleSwitch'; import { LocaleSwitch } from '../LocaleSwitch/LocaleSwitch';
import { RainbowSeparator } from '../RainbowSeparator'; import { RainbowSeparator } from '../RainbowSeparator';
import { t } from './Footer.i18n'; import { t, tRaw } from './Footer.i18n';
const umamiDisabledStorage = createLocalStorage<number | null>('umami.disabled', null);
/** /**
* Props for the Footer component. * Props for the Footer component.
@@ -17,7 +20,7 @@ export interface FooterProps {
/** /**
* The standard footer for the www service. * The standard footer for the www service.
* Displays copyright information, license, and theme switcher. * Displays copyright information, license, privacy policy, and theme switcher.
* *
* @returns A Footer component. * @returns A Footer component.
* @example * @example
@@ -27,33 +30,65 @@ export interface FooterProps {
*/ */
export const Footer = ({ currentPath }: FooterProps) => { export const Footer = ({ currentPath }: FooterProps) => {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const [umamiDisabled, setUmamiDisabled] = useLocalStorage(umamiDisabledStorage);
const isTelemetryEnabled = umamiDisabled !== 1;
const handleTelemetryToggle = useCallback(
(isSelected: boolean): void => {
if (isSelected) {
umamiDisabledStorage.remove();
} else {
setUmamiDisabled(1);
}
},
[setUmamiDisabled]
);
return ( return (
<VStack as="footer" className="w-full" gap={0}> <VStack align="stretch" as="footer" gap={0}>
<RainbowSeparator /> <RainbowSeparator />
<Container bg="transparent" className="w-full max-sm:p-8" padding={12}> <Container bg="transparent" padding={12}>
<HStack <VStack align="stretch" gap={1}>
align="center" <HStack align="center" gap={4} justify="between" wrap="wrap">
className="w-full max-sm:flex-col max-sm:items-start max-sm:gap-8"
justify="between"
>
<VStack align="start" gap={1}>
<Text color="muted" size="s"> <Text color="muted" size="s">
© {currentYear} {t('Vladimir Eremin')} © {currentYear} {t('Vladimir Eremin')} ·{' '}
<Link href="https://creativecommons.org/licenses/by/4.0/" size="s">
CC-BY 4.0
</Link>
</Text> </Text>
<Link href="https://creativecommons.org/licenses/by/4.0/" size="s">
CC-BY 4.0
</Link>
</VStack>
<VStack align="end" gap={4}>
<ThemeSwitch <ThemeSwitch
darkIcon={<Icon icon={Moon} />} darkIcon={<Icon icon={Moon} />}
lightIcon={<Icon icon={Sun} />} lightIcon={<Icon icon={Sun} />}
systemIcon={<Icon icon={Monitor} />} systemIcon={<Icon icon={Monitor} />}
/> />
</HStack>
<HStack align="center" gap={4} justify="between" wrap="wrap">
<HStack align="center" gap={2} wrap="wrap">
<Text color="muted" size="s" type="label">
{tRaw('To make this site better, I collect anonymous analytics · {privacyLink}', {
privacyLink: (
<Link
key="privacy-link"
href="https://static.ermnvldmr.com/www/privacy-policy.txt"
size="s"
type="label"
>
{t('Privacy Policy')}
</Link>
),
})}
</Text>
<Switch
aria-label={t('Toggle anonymous telemetry')}
isSelected={isTelemetryEnabled}
size="s"
variant="outline"
onChange={handleTelemetryToggle}
/>
</HStack>
<LocaleSwitch currentPath={currentPath} /> <LocaleSwitch currentPath={currentPath} />
</VStack> </HStack>
</HStack> </VStack>
</Container> </Container>
</VStack> </VStack>
); );
@@ -1,6 +1,7 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
@@ -12,7 +13,10 @@ describe('ArticleLayout', () => {
createdDate: new Date('2026-01-01T00:00:00Z'), createdDate: new Date('2026-01-01T00:00:00Z'),
}; };
it('renders title, created date, and content', () => { it('renders title, created date, and content', async () => {
const user = userEvent.setup();
window.localStorage.removeItem('umami.disabled');
render( render(
<ArticleLayout {...defaultProps}> <ArticleLayout {...defaultProps}>
Article content body Article content body
@@ -21,6 +25,21 @@ describe('ArticleLayout', () => {
expect(screen.getByRole('heading', { level: 1, name: 'Test Article Title' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 1, name: 'Test Article Title' })).toBeInTheDocument();
expect(screen.getByText('Article content body')).toBeInTheDocument(); expect(screen.getByText('Article content body')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Privacy Policy' })).toHaveAttribute(
'href',
'https://static.ermnvldmr.com/www/privacy-policy.txt'
);
const telemetrySwitch = screen.getByRole('switch', { name: 'Toggle anonymous telemetry' });
expect(telemetrySwitch).toBeChecked();
await user.click(telemetrySwitch);
expect(telemetrySwitch).not.toBeChecked();
expect(window.localStorage.getItem('umami.disabled')).toBe('1');
await user.click(telemetrySwitch);
expect(telemetrySwitch).toBeChecked();
expect(window.localStorage.getItem('umami.disabled')).toBeNull();
}); });
it('renders updated date when updatedDate prop is provided', () => { it('renders updated date when updatedDate prop is provided', () => {
@@ -23,11 +23,12 @@ export const LocaleSwitch: React.FC<LocaleSwitchProps> = ({ currentPath }) => {
return ( return (
<Link <Link
className="text-neutral-400 hover:text-neutral-500 dark:text-neutral-500 dark:hover:text-neutral-400" color="muted"
data-umami-event="locale-switch" data-umami-event="locale-switch"
data-umami-event-target={OTHER_LOCALE} data-umami-event-target={OTHER_LOCALE}
href={href} href={href}
size="s" size="s"
type="label"
> >
{OTHER_LOCALE_LABEL} {OTHER_LOCALE_LABEL}
</Link> </Link>