fix(analytics): serve umami via /trace/trace.js and add privacy policy with opt-out switch (#71)
This commit is contained in:
1 parent
bdd9fc4602
commit
84772ffdd6
21 files changed
+272
-51
No files matched your search
@@ -6,7 +6,7 @@ export default defineCli({
|
||||
name: 'sync:static',
|
||||
description: 'Sync static assets to S3',
|
||||
targetPrefix: 'docs',
|
||||
sourceDir: './static',
|
||||
sourceDir: './public/static',
|
||||
sourceFilesPattern: 'content/**/*.{md,html}',
|
||||
urlPrefixes: ['/static/', '/images/'],
|
||||
bucket: env('COM_ERMNVLDMR_STATIC_S3_BUCKET'),
|
||||
@@ -16,7 +16,7 @@ export default defineCli({
|
||||
accessKeyId: env('COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID'),
|
||||
secretAccessKey: env('COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY'),
|
||||
hooks: {
|
||||
beforeSync: 'bash ../../ci/scripts/optimize-images.sh ./static',
|
||||
beforeSync: 'bash ../../ci/scripts/optimize-images.sh ./public/static',
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -48,10 +48,10 @@ params:
|
||||
# Analytics configuration
|
||||
analytics:
|
||||
umami:
|
||||
serverURL: '/beacon'
|
||||
scriptName: 'beacon.js'
|
||||
hostURL: '/beacon'
|
||||
websiteID: 'fbdadf3f-3484-4186-a2c2-cedcf76cf44f'
|
||||
serverURL: '/trace'
|
||||
scriptName: 'trace.js'
|
||||
hostURL: '/trace'
|
||||
websiteID: '6e65136b-48c2-4519-816f-023932ceb775'
|
||||
domains: 'docs.ermnvldmr.com'
|
||||
doNotTrack: 'true'
|
||||
|
||||
|
||||
@@ -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,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: Кстати
|
||||
tip: Полезно
|
||||
important: Важно
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 311 KiB |
@@ -2,13 +2,14 @@ import { localDevServer } from '@ermnvldmr/rsbuild-config/dev';
|
||||
import { defineSSGServiceConfig, discoverEntries, umamiAnalytics } from '@ermnvldmr/ssg/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({
|
||||
server: localDevServer({ port: 3001 }),
|
||||
analytics: [
|
||||
umamiAnalytics({
|
||||
serverUrl: 'https://umami.ermnvldmr.com',
|
||||
scriptName: 'trace.js',
|
||||
websiteId: UMAMI_STATIC_WEBSITE_ID,
|
||||
domains: 'static.ermnvldmr.com',
|
||||
doNotTrack: true,
|
||||
|
||||
@@ -33,18 +33,18 @@ const MIME: Record<string, string> = {
|
||||
'.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(
|
||||
mergeConfig(localeRsbuildConfig(), {
|
||||
server: localDevServer({ port: 3000 }),
|
||||
analytics: [
|
||||
umamiAnalytics({
|
||||
serverUrl: '/beacon',
|
||||
scriptName: 'beacon.js',
|
||||
hostUrl: '/beacon',
|
||||
serverUrl: '/trace',
|
||||
scriptName: 'trace.js',
|
||||
hostUrl: '/trace',
|
||||
websiteId: UMAMI_WWW_WEBSITE_ID,
|
||||
domains: 'ermnvldmr.com,www.ermnvldmr.com',
|
||||
domains: 'www.ermnvldmr.com',
|
||||
doNotTrack: true,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { createKeyset } from '@ermnvldmr/i18n';
|
||||
|
||||
export const { t } = createKeyset({
|
||||
export const { t, tRaw } = createKeyset({
|
||||
'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}',
|
||||
},
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
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 React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { LocaleSwitch } from '../LocaleSwitch/LocaleSwitch';
|
||||
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.
|
||||
@@ -17,7 +20,7 @@ export interface FooterProps {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @example
|
||||
@@ -27,33 +30,65 @@ export interface FooterProps {
|
||||
*/
|
||||
export const Footer = ({ currentPath }: FooterProps) => {
|
||||
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 (
|
||||
<VStack as="footer" className="w-full" gap={0}>
|
||||
<VStack align="stretch" as="footer" gap={0}>
|
||||
<RainbowSeparator />
|
||||
<Container bg="transparent" className="w-full max-sm:p-8" padding={12}>
|
||||
<HStack
|
||||
align="center"
|
||||
className="w-full max-sm:flex-col max-sm:items-start max-sm:gap-8"
|
||||
justify="between"
|
||||
>
|
||||
<VStack align="start" gap={1}>
|
||||
<Container bg="transparent" padding={12}>
|
||||
<VStack align="stretch" gap={1}>
|
||||
<HStack align="center" gap={4} justify="between" wrap="wrap">
|
||||
<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>
|
||||
<Link href="https://creativecommons.org/licenses/by/4.0/" size="s">
|
||||
CC-BY 4.0
|
||||
</Link>
|
||||
</VStack>
|
||||
<VStack align="end" gap={4}>
|
||||
<ThemeSwitch
|
||||
darkIcon={<Icon icon={Moon} />}
|
||||
lightIcon={<Icon icon={Sun} />}
|
||||
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} />
|
||||
</VStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -12,7 +13,10 @@ describe('ArticleLayout', () => {
|
||||
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(
|
||||
<ArticleLayout {...defaultProps}>
|
||||
Article content body
|
||||
@@ -21,6 +25,21 @@ describe('ArticleLayout', () => {
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Test Article Title' })).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', () => {
|
||||
|
||||
@@ -23,11 +23,12 @@ export const LocaleSwitch: React.FC<LocaleSwitchProps> = ({ currentPath }) => {
|
||||
|
||||
return (
|
||||
<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-target={OTHER_LOCALE}
|
||||
href={href}
|
||||
size="s"
|
||||
type="label"
|
||||
>
|
||||
{OTHER_LOCALE_LABEL}
|
||||
</Link>
|
||||
|
||||
Reference in new issue
Block a user