api: improve user-agent parsing

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-12-08 14:38:11 +01:00
parent abf5353ab3
commit d3522c51f8
4 changed files with 320 additions and 49 deletions

View File

@@ -16,20 +16,20 @@
"dependencies": { "dependencies": {
"@openpanel/constants": "workspace:*", "@openpanel/constants": "workspace:*",
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"lru-cache": "^11.2.2", "lru-cache": "^11.2.4",
"luxon": "^3.6.1", "luxon": "^3.7.2",
"mathjs": "^12.3.2", "mathjs": "^12.3.2",
"nanoid": "^5.0.7", "nanoid": "^5.1.6",
"ramda": "^0.29.1", "ramda": "^0.29.1",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"superjson": "^1.13.3", "superjson": "^1.13.3",
"ua-parser-js": "^1.0.37", "ua-parser-js": "^2.0.6",
"unique-names-generator": "^4.7.1" "unique-names-generator": "^4.7.1"
}, },
"devDependencies": { "devDependencies": {
"@openpanel/tsconfig": "workspace:*", "@openpanel/tsconfig": "workspace:*",
"@openpanel/validation": "workspace:*", "@openpanel/validation": "workspace:*",
"@types/luxon": "^3.6.2", "@types/luxon": "^3.7.1",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/ramda": "^0.29.6", "@types/ramda": "^0.29.6",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",

View File

@@ -57,7 +57,7 @@ describe('parseUserAgent', () => {
expect(parseUserAgent(ua)).toEqual({ expect(parseUserAgent(ua)).toEqual({
isServer: false, isServer: false,
device: 'tablet', device: 'tablet',
os: 'Mac OS', os: 'iOS',
osVersion: '18.0', osVersion: '18.0',
browser: 'WebKit', browser: 'WebKit',
browserVersion: '605.1.15', browserVersion: '605.1.15',
@@ -70,16 +70,15 @@ describe('parseUserAgent', () => {
const ua = const ua =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36';
expect(parseUserAgent(ua)).toEqual({ const result = parseUserAgent(ua);
isServer: false, expect(result.isServer).toBe(false);
device: 'desktop', expect(result.device).toBe('desktop');
os: 'Windows', expect(result.os).toBe('Windows');
osVersion: '10', expect(result.osVersion).toBe('10');
browser: 'Chrome', expect(result.browser).toBe('Chrome');
browserVersion: '91.0.4472.124', expect(result.browserVersion).toBe('91.0.4472.124');
brand: undefined, // Desktop browsers don't have brand/model
model: undefined, expect(result.model).toBeUndefined();
});
}); });
it('should handle server user agents', () => { it('should handle server user agents', () => {
@@ -167,4 +166,85 @@ describe('getDevice', () => {
expect(getDevice(ua)).toBe('desktop'); expect(getDevice(ua)).toBe('desktop');
}); });
}); });
it('should detect known phone models as mobile', () => {
// Xiaomi/Redmi
expect(getDevice('', 'Redmi Note 8 Pro')).toBe('mobile');
expect(getDevice('', 'POCO F3')).toBe('mobile');
expect(getDevice('', 'Mi 11')).toBe('mobile');
// Samsung Galaxy phones
expect(getDevice('', 'Galaxy S23 Ultra')).toBe('mobile');
expect(getDevice('', 'Galaxy A54')).toBe('mobile');
expect(getDevice('', 'Galaxy Z Fold5')).toBe('mobile');
// Google Pixel
expect(getDevice('', 'Pixel 8 Pro')).toBe('mobile');
// OnePlus
expect(getDevice('', 'OnePlus 11')).toBe('mobile');
// Huawei/Honor
expect(getDevice('', 'Huawei P60 Pro')).toBe('mobile');
expect(getDevice('', 'Honor 90')).toBe('mobile');
// OPPO/Vivo/Realme
expect(getDevice('', 'OPPO Find X6')).toBe('mobile');
expect(getDevice('', 'Vivo X90')).toBe('mobile');
expect(getDevice('', 'Realme GT5')).toBe('mobile');
// Motorola
expect(getDevice('', 'Moto G84')).toBe('mobile');
});
});
describe('parseUserAgent - brand detection', () => {
it('should detect Xiaomi brand from Manufacturer field', () => {
const result = parseUserAgent(
'App/1.0 (Android 12; Model=POCO X5; Manufacturer=Xiaomi)',
);
expect(result.brand).toBe('Xiaomi');
expect(result.model).toBe('POCO X5');
expect(result.device).toBe('mobile');
});
it('should detect Samsung brand from model name', () => {
const result = parseUserAgent(
'App/1.0 (Android 13; Model=Galaxy S23 Ultra)',
);
expect(result.brand).toBe('Samsung');
expect(result.model).toBe('Galaxy S23 Ultra');
expect(result.device).toBe('mobile');
});
it('should detect Google Pixel', () => {
const result = parseUserAgent('App/1.0 (Android 14; Model=Pixel 8 Pro)');
expect(result.brand).toBe('Google');
expect(result.model).toBe('Pixel 8 Pro');
expect(result.os).toBe('Android');
expect(result.osVersion).toBe('14');
expect(result.device).toBe('mobile');
});
it('should detect OnePlus', () => {
const result = parseUserAgent(
'App/1.0 (Android 13; Model=OnePlus 11; Manufacturer=OnePlus)',
);
expect(result.brand).toBe('OnePlus');
expect(result.model).toBe('OnePlus 11');
expect(result.device).toBe('mobile');
expect(result.os).toBe('Android');
expect(result.osVersion).toBe('13');
});
it('should detect Huawei', () => {
const result = parseUserAgent(
'App/1.0 (Android 12; Model=P60 Pro; Manufacturer=Huawei)',
);
expect(result.brand).toBe('Huawei');
expect(result.model).toBe('P60 Pro');
expect(result.device).toBe('mobile');
expect(result.os).toBe('Android');
expect(result.osVersion).toBe('12');
});
}); });

View File

@@ -18,6 +18,10 @@ const IOS_MODEL_REGEX = /(iOS)\s*([0-9\.]+)/i;
const IPAD_OS_VERSION_REGEX = /iPadOS\s*([0-9_]+)/i; const IPAD_OS_VERSION_REGEX = /iPadOS\s*([0-9_]+)/i;
const SINGLE_NAME_VERSION_REGEX = /^[^\/]+\/[\d.]+$/; const SINGLE_NAME_VERSION_REGEX = /^[^\/]+\/[\d.]+$/;
// App-style UA patterns (e.g., "Model=Redmi Note 8 Pro; Manufacturer=Xiaomi")
const APP_MODEL_REGEX = /Model=([^;)]+)/i;
const APP_MANUFACTURER_REGEX = /Manufacturer=([^;)]+)/i;
// Device detection regexes // Device detection regexes
const SAMSUNG_MOBILE_REGEX = /SM-[ABDEFGJMNRWZ][0-9]+/i; const SAMSUNG_MOBILE_REGEX = /SM-[ABDEFGJMNRWZ][0-9]+/i;
const SAMSUNG_TABLET_REGEX = /SM-T[0-9]+/i; const SAMSUNG_TABLET_REGEX = /SM-T[0-9]+/i;
@@ -30,6 +34,76 @@ const TABLET_REGEX = /tablet|ipad|xoom|sch-i800|kindle|silk|playbook/i;
const ANDROID_REGEX = /android/i; const ANDROID_REGEX = /android/i;
const MOBILE_KEYWORD_REGEX = /mobile/i; const MOBILE_KEYWORD_REGEX = /mobile/i;
// Known phone model patterns from top brands (for device type detection)
// These patterns indicate mobile phones, not tablets
const KNOWN_PHONE_PATTERNS = [
// Xiaomi / Redmi / POCO
/redmi\s*(note|k|[0-9])/i,
/poco\s*[a-z0-9]/i,
/mi\s*([0-9]|note|mix|max)/i,
// Samsung Galaxy phones (not tablets)
/galaxy\s*(s|a|m|note)[0-9]/i,
/galaxy\s*z\s*(fold|flip)/i, // Foldables
// Huawei / Honor
/huawei\s*(p|mate|nova|y)[0-9]/i,
/honor\s*[0-9a-z]/i,
// OPPO
/oppo\s*(a|f|find|reno|k)\s*[a-z0-9]/i,
// Vivo
/vivo\s*(v|y|x|s|t|iqoo)[0-9]/i,
/iqoo\s*[0-9a-z]/i,
// OnePlus
/oneplus\s*[0-9]/i,
/one\s*plus\s*[0-9]/i,
// Google Pixel
/pixel\s*[0-9]/i,
// Realme
/realme\s*[0-9a-z]/i,
// Motorola
/moto\s*(g|e|x|z|edge|razr)/i,
/motorola\s*(edge|razr)/i,
// Nokia
/nokia\s*[0-9]/i,
// Sony Xperia
/xperia\s*[0-9a-z]/i,
// Nothing
/nothing\s*phone/i,
];
// Brand detection patterns with normalized names
// Order matters - more specific patterns should come first
const BRAND_PATTERNS: Array<{ pattern: RegExp; brand: string }> = [
{ pattern: /xiaomi/i, brand: 'Xiaomi' },
{ pattern: /redmi/i, brand: 'Xiaomi' },
{ pattern: /poco/i, brand: 'Xiaomi' },
{ pattern: /samsung/i, brand: 'Samsung' },
{ pattern: /galaxy/i, brand: 'Samsung' },
{ pattern: /huawei/i, brand: 'Huawei' },
{ pattern: /honor/i, brand: 'Honor' },
{ pattern: /oppo/i, brand: 'OPPO' },
{ pattern: /vivo/i, brand: 'Vivo' },
{ pattern: /iqoo/i, brand: 'Vivo' },
{ pattern: /oneplus|one\s*plus/i, brand: 'OnePlus' },
{ pattern: /google/i, brand: 'Google' },
{ pattern: /pixel/i, brand: 'Google' },
{ pattern: /realme/i, brand: 'Realme' },
{ pattern: /motorola/i, brand: 'Motorola' },
{ pattern: /moto\s/i, brand: 'Motorola' },
{ pattern: /nokia/i, brand: 'Nokia' },
{ pattern: /sony/i, brand: 'Sony' },
{ pattern: /xperia/i, brand: 'Sony' },
{ pattern: /nothing/i, brand: 'Nothing' },
// Be specific with Apple - don't match "AppleWebKit"
{ pattern: /\bapple\b(?!webkit)/i, brand: 'Apple' },
{ pattern: /\biphone\b/i, brand: 'Apple' },
{ pattern: /\bipad\b/i, brand: 'Apple' },
{ pattern: /lg[- ]/i, brand: 'LG' },
{ pattern: /zte/i, brand: 'ZTE' },
{ pattern: /lenovo/i, brand: 'Lenovo' },
{ pattern: /asus/i, brand: 'ASUS' },
{ pattern: /tcl/i, brand: 'TCL' },
];
// Cache for parsed results - stores up to 1000 unique user agents // Cache for parsed results - stores up to 1000 unique user agents
const parseCache = new LRUCache<string, UAParser.IResult>({ const parseCache = new LRUCache<string, UAParser.IResult>({
ttl: 1000 * 60 * 5, ttl: 1000 * 60 * 5,
@@ -49,6 +123,37 @@ const isIphone = (ua: string) => {
: null; : null;
}; };
// Extract app-style UA info (e.g., "Model=Redmi Note 8 Pro; Manufacturer=Xiaomi")
function extractAppStyleInfo(ua: string): {
model?: string;
manufacturer?: string;
} {
const modelMatch = ua.match(APP_MODEL_REGEX);
const manufacturerMatch = ua.match(APP_MANUFACTURER_REGEX);
return {
model: modelMatch?.[1]?.trim(),
manufacturer: manufacturerMatch?.[1]?.trim(),
};
}
// Detect brand from UA string or model name
function detectBrand(ua: string, model?: string): string | undefined {
const searchString = `${ua} ${model || ''}`;
for (const { pattern, brand } of BRAND_PATTERNS) {
if (pattern.test(searchString)) {
return brand;
}
}
return undefined;
}
// Check if a model name indicates a phone (not tablet)
function isKnownPhoneModel(model?: string): boolean {
if (!model) return false;
return KNOWN_PHONE_PATTERNS.some((pattern) => pattern.test(model));
}
const parse = (ua: string): UAParser.IResult => { const parse = (ua: string): UAParser.IResult => {
// Check cache first // Check cache first
const cached = parseCache.get(ua); const cached = parseCache.get(ua);
@@ -57,7 +162,7 @@ const parse = (ua: string): UAParser.IResult => {
} }
const parser = new UAParser(ua); const parser = new UAParser(ua);
const res = parser.getResult(); let res = parser.getResult();
// Some user agents are not detected correctly by ua-parser-js // Some user agents are not detected correctly by ua-parser-js
// Doing some extra checks for ios // Doing some extra checks for ios
@@ -86,15 +191,45 @@ const parse = (ua: string): UAParser.IResult => {
if (res.device.model === 'iPad' && !res.os.version) { if (res.device.model === 'iPad' && !res.os.version) {
const osVersion = ua.match(IPAD_OS_VERSION_REGEX); const osVersion = ua.match(IPAD_OS_VERSION_REGEX);
if (osVersion) { if (osVersion) {
const result = { res = {
...res, ...res,
os: { os: {
...res.os, ...res.os,
version: osVersion[1]!.replace(/_/g, '.'), version: osVersion[1]!.replace(/_/g, '.'),
}, },
}; };
parseCache.set(ua, result); }
return result; }
// Extract app-style UA info (e.g., "Model=Redmi Note 8 Pro; Manufacturer=Xiaomi")
// This handles UAs from mobile apps that include device info in a custom format
const appInfo = extractAppStyleInfo(ua);
if (appInfo.model || appInfo.manufacturer) {
const model = res.device.model || appInfo.model;
const vendor =
res.device.vendor || appInfo.manufacturer || detectBrand(ua, model);
res = {
...res,
device: {
...res.device,
model: model,
vendor: vendor,
},
};
}
// If we still don't have a vendor, try to detect brand from UA or model
if (!res.device.vendor && (res.device.model || res.os.name)) {
const detectedBrand = detectBrand(ua, res.device.model);
if (detectedBrand) {
res = {
...res,
device: {
...res.device,
vendor: detectedBrand,
},
};
} }
} }
@@ -116,6 +251,11 @@ export function parseUserAgent(
return parsedServerUa; return parsedServerUa;
} }
const model =
typeof overrides?.__model === 'string' && overrides?.__model
? overrides?.__model
: res.device.model;
return { return {
os: os:
typeof overrides?.__os === 'string' && overrides?.__os typeof overrides?.__os === 'string' && overrides?.__os
@@ -137,15 +277,12 @@ export function parseUserAgent(
device: device:
typeof overrides?.__device === 'string' && overrides?.__device typeof overrides?.__device === 'string' && overrides?.__device
? overrides?.__device ? overrides?.__device
: res.device.type || getDevice(ua), : res.device.type || getDevice(ua, model),
brand: brand:
typeof overrides?.__brand === 'string' && overrides?.__brand typeof overrides?.__brand === 'string' && overrides?.__brand
? overrides?.__brand ? overrides?.__brand
: res.device.vendor, : res.device.vendor,
model: model,
typeof overrides?.__model === 'string' && overrides?.__model
? overrides?.__model
: res.device.model,
isServer: false, isServer: false,
} as const; } as const;
} }
@@ -166,7 +303,13 @@ function isServer(res: UAParser.IResult) {
); );
} }
export function getDevice(ua: string) { export function getDevice(ua: string, model?: string) {
// Check for known phone models first (from top brands)
// This handles app-style UAs where the model is explicitly stated
if (isKnownPhoneModel(model) || isKnownPhoneModel(ua)) {
return 'mobile';
}
// Samsung mobile devices use SM-[A,G,N,etc]XXX pattern // Samsung mobile devices use SM-[A,G,N,etc]XXX pattern
const isSamsungMobile = SAMSUNG_MOBILE_REGEX.test(ua); const isSamsungMobile = SAMSUNG_MOBILE_REGEX.test(ua);
if (isSamsungMobile) { if (isSamsungMobile) {
@@ -197,13 +340,30 @@ export function getDevice(ua: string) {
const isAndroid = ANDROID_REGEX.test(ua); const isAndroid = ANDROID_REGEX.test(ua);
const hasMobileKeyword = MOBILE_KEYWORD_REGEX.test(ua); const hasMobileKeyword = MOBILE_KEYWORD_REGEX.test(ua);
const tablet = // Only consider it a tablet if it's explicitly a tablet device
TABLET_REGEX.test(ua) || // Don't default Android to tablet just because "mobile" keyword is missing
(isAndroid && !hasMobileKeyword && !isSamsungMobile && !isLGMobile); const tablet = TABLET_REGEX.test(ua);
if (tablet) { if (tablet) {
return 'tablet'; return 'tablet';
} }
// For Android without explicit mobile/tablet indicators and no known model,
// check if there's any brand/model info suggesting it's a phone
if (isAndroid && !hasMobileKeyword && !isSamsungMobile && !isLGMobile) {
// Extract model from app-style UA if present
const appInfo = extractAppStyleInfo(ua);
if (appInfo.model && isKnownPhoneModel(appInfo.model)) {
return 'mobile';
}
// If we have a brand but no clear device type, assume mobile for Android
const brand = detectBrand(ua, appInfo.model);
if (brand) {
return 'mobile';
}
// Default Android without clear indicators to mobile (more common than tablet)
return 'mobile';
}
return 'desktop'; return 'desktop';
} }

71
pnpm-lock.yaml generated
View File

@@ -978,17 +978,17 @@ importers:
specifier: ^3.3.1 specifier: ^3.3.1
version: 3.3.1 version: 3.3.1
lru-cache: lru-cache:
specifier: ^11.2.2 specifier: ^11.2.4
version: 11.2.2 version: 11.2.4
luxon: luxon:
specifier: ^3.6.1 specifier: ^3.7.2
version: 3.6.1 version: 3.7.2
mathjs: mathjs:
specifier: ^12.3.2 specifier: ^12.3.2
version: 12.3.2 version: 12.3.2
nanoid: nanoid:
specifier: ^5.0.7 specifier: ^5.1.6
version: 5.0.7 version: 5.1.6
ramda: ramda:
specifier: ^0.29.1 specifier: ^0.29.1
version: 0.29.1 version: 0.29.1
@@ -999,8 +999,8 @@ importers:
specifier: ^1.13.3 specifier: ^1.13.3
version: 1.13.3 version: 1.13.3
ua-parser-js: ua-parser-js:
specifier: ^1.0.37 specifier: ^2.0.6
version: 1.0.37 version: 2.0.6
unique-names-generator: unique-names-generator:
specifier: ^4.7.1 specifier: ^4.7.1
version: 4.7.1 version: 4.7.1
@@ -1012,8 +1012,8 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../validation version: link:../validation
'@types/luxon': '@types/luxon':
specifier: ^3.6.2 specifier: ^3.7.1
version: 3.6.2 version: 3.7.1
'@types/node': '@types/node':
specifier: 'catalog:' specifier: 'catalog:'
version: 24.7.1 version: 24.7.1
@@ -8551,8 +8551,8 @@ packages:
'@types/lodash@4.14.202': '@types/lodash@4.14.202':
resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==}
'@types/luxon@3.6.2': '@types/luxon@3.7.1':
resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==} resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
'@types/mdast@4.0.3': '@types/mdast@4.0.3':
resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==} resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==}
@@ -10260,6 +10260,9 @@ packages:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
detect-europe-js@0.1.2:
resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==}
detect-file@1.0.0: detect-file@1.0.0:
resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -11911,6 +11914,9 @@ packages:
resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-standalone-pwa@0.1.1:
resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==}
is-stream@1.1.0: is-stream@1.1.0:
resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -12553,6 +12559,10 @@ packages:
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
lru-cache@11.2.4:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -12570,8 +12580,8 @@ packages:
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
luxon@3.6.1: luxon@3.7.2:
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==} resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'} engines: {node: '>=12'}
lz-string@1.5.0: lz-string@1.5.0:
@@ -13115,8 +13125,8 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
nanoid@5.0.7: nanoid@5.1.6:
resolution: {integrity: sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==} resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
engines: {node: ^18 || >=20} engines: {node: ^18 || >=20}
hasBin: true hasBin: true
@@ -15780,9 +15790,16 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
ua-is-frozen@0.1.2:
resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==}
ua-parser-js@1.0.37: ua-parser-js@1.0.37:
resolution: {integrity: sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==} resolution: {integrity: sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==}
ua-parser-js@2.0.6:
resolution: {integrity: sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg==}
hasBin: true
ufo@1.6.1: ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
@@ -24170,7 +24187,7 @@ snapshots:
'@types/lodash@4.14.202': {} '@types/lodash@4.14.202': {}
'@types/luxon@3.6.2': {} '@types/luxon@3.7.1': {}
'@types/mdast@4.0.3': '@types/mdast@4.0.3':
dependencies: dependencies:
@@ -25735,7 +25752,7 @@ snapshots:
cron-parser@4.9.0: cron-parser@4.9.0:
dependencies: dependencies:
luxon: 3.6.1 luxon: 3.7.2
croner@9.1.0: {} croner@9.1.0: {}
@@ -26180,6 +26197,8 @@ snapshots:
destroy@1.2.0: {} destroy@1.2.0: {}
detect-europe-js@0.1.2: {}
detect-file@1.0.0: {} detect-file@1.0.0: {}
detect-libc@1.0.3: {} detect-libc@1.0.3: {}
@@ -28458,6 +28477,8 @@ snapshots:
dependencies: dependencies:
call-bind: 1.0.7 call-bind: 1.0.7
is-standalone-pwa@0.1.1: {}
is-stream@1.1.0: {} is-stream@1.1.0: {}
is-stream@2.0.1: {} is-stream@2.0.1: {}
@@ -29096,6 +29117,8 @@ snapshots:
lru-cache@11.2.2: {} lru-cache@11.2.2: {}
lru-cache@11.2.4: {}
lru-cache@5.1.1: lru-cache@5.1.1:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
@@ -29112,7 +29135,7 @@ snapshots:
dependencies: dependencies:
react: 19.1.1 react: 19.1.1
luxon@3.6.1: {} luxon@3.7.2: {}
lz-string@1.5.0: {} lz-string@1.5.0: {}
@@ -30054,7 +30077,7 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
nanoid@5.0.7: {} nanoid@5.1.6: {}
ncp@2.0.0: ncp@2.0.0:
optional: true optional: true
@@ -33309,8 +33332,16 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
ua-is-frozen@0.1.2: {}
ua-parser-js@1.0.37: {} ua-parser-js@1.0.37: {}
ua-parser-js@2.0.6:
dependencies:
detect-europe-js: 0.1.2
is-standalone-pwa: 0.1.1
ua-is-frozen: 0.1.2
ufo@1.6.1: {} ufo@1.6.1: {}
ultrahtml@1.6.0: {} ultrahtml@1.6.0: {}