From c9be7f4c6bb5e4a57a478f8b953416f5efe3bcbe Mon Sep 17 00:00:00 2001 From: Alex Sanders Date: Wed, 31 Aug 2022 16:27:57 +0100 Subject: [PATCH 1/4] add `@guardian/libs` code --- README.md | 1 + libs/@guardian/libs/import | 27 - libs/@guardian/libs/package.json | 7 +- .../libs/scripts/generateSvg.logger.teams.ts | 89 ++ libs/@guardian/libs/src/@types/window.d.ts | 20 + .../ArticleElementRole.test.ts | 5 + .../ArticleElementRole/ArticleElementRole.ts | 15 + .../libs/src/ArticleElementRole/README.md | 11 + .../libs/src/cookies/ERR_INVALID_COOKIE.ts | 1 + libs/@guardian/libs/src/cookies/README.md | 132 +++ .../libs/src/cookies/cookies.test.ts | 311 ++++++ libs/@guardian/libs/src/cookies/getCookie.ts | 30 + .../libs/src/cookies/getCookieValues.ts | 15 + .../libs/src/cookies/getDomainAttribute.ts | 6 + .../libs/src/cookies/getShortDomain.ts | 14 + .../libs/src/cookies/isValidCookie.ts | 7 + .../libs/src/cookies/memoizedCookies.ts | 1 + .../libs/src/cookies/removeCookie.ts | 26 + libs/@guardian/libs/src/cookies/setCookie.ts | 53 + .../libs/src/cookies/setSessionCookie.ts | 34 + .../@types/CoreWebVitalsPayload.ts | 9 + .../libs/src/coreWebVitals/README.md | 108 ++ .../libs/src/coreWebVitals/index.test.ts | 385 +++++++ .../@guardian/libs/src/coreWebVitals/index.ts | 207 ++++ .../coreWebVitals/roundWithDecimals.test.ts | 24 + .../src/coreWebVitals/roundWithDecimals.ts | 4 + .../libs/src/countries/@types/Country.ts | 6 + .../libs/src/countries/@types/CountryCode.ts | 251 +++++ .../libs/src/countries/@types/CountryKey.ts | 249 +++++ libs/@guardian/libs/src/countries/README.md | 64 ++ .../libs/src/countries/countries.test.ts | 13 + .../@guardian/libs/src/countries/countries.ts | 997 ++++++++++++++++++ .../countries/getCountryByCountryCode.test.ts | 10 + .../src/countries/getCountryByCountryCode.ts | 12 + libs/@guardian/libs/src/datetime/README.md | 37 + .../libs/src/datetime/timeAgo.test.ts | 228 ++++ libs/@guardian/libs/src/datetime/timeAgo.ts | 114 ++ .../libs/src/format/ArticleDesign.ts | 26 + .../libs/src/format/ArticleDisplay.ts | 6 + .../libs/src/format/ArticleFormat.ts | 9 + .../libs/src/format/ArticlePillar.ts | 7 + .../libs/src/format/ArticleSpecial.ts | 4 + .../@guardian/libs/src/format/ArticleTheme.ts | 4 + libs/@guardian/libs/src/format/README.md | 15 + libs/@guardian/libs/src/format/format.test.ts | 20 + libs/@guardian/libs/src/index.test.ts | 54 + libs/@guardian/libs/src/index.ts | 59 +- libs/@guardian/libs/src/isBoolean/README.md | 14 + .../libs/src/isBoolean/isBoolean.test.ts | 28 + .../@guardian/libs/src/isBoolean/isBoolean.ts | 3 + libs/@guardian/libs/src/isObject/README.md | 14 + .../libs/src/isObject/isObject.test.ts | 29 + libs/@guardian/libs/src/isObject/isObject.ts | 13 + libs/@guardian/libs/src/isString/README.md | 14 + .../libs/src/isString/isString.test.ts | 28 + libs/@guardian/libs/src/isString/isString.ts | 3 + libs/@guardian/libs/src/isUndefined/README.md | 17 + .../libs/src/isUndefined/isUndefined.test.ts | 30 + .../libs/src/isUndefined/isUndefined.ts | 3 + libs/@guardian/libs/src/joinUrl/README.md | 16 + .../libs/src/joinUrl/joinUrl.test.ts | 48 + libs/@guardian/libs/src/joinUrl/joinUrl.ts | 5 + libs/@guardian/libs/src/loadScript/README.md | 37 + .../libs/src/loadScript/loadScript.test.ts | 80 ++ .../libs/src/loadScript/loadScript.ts | 35 + libs/@guardian/libs/src/locale/README.md | 23 + .../libs/src/locale/getLocale.test.ts | 74 ++ libs/@guardian/libs/src/locale/getLocale.ts | 60 ++ .../libs/src/logger/@types/logger.ts | 7 + libs/@guardian/libs/src/logger/README.md | 92 ++ libs/@guardian/libs/src/logger/debug.ts | 13 + libs/@guardian/libs/src/logger/log.ts | 67 ++ libs/@guardian/libs/src/logger/logger.test.ts | 134 +++ libs/@guardian/libs/src/logger/storage-key.ts | 1 + libs/@guardian/libs/src/logger/teamStyles.ts | 51 + libs/@guardian/libs/src/ophan/@types/index.ts | 112 ++ libs/@guardian/libs/src/ophan/README.md | 28 + libs/@guardian/libs/src/storage/README.md | 132 +++ .../libs/src/storage/storage.test.ts | 134 +++ libs/@guardian/libs/src/storage/storage.ts | 128 +++ .../libs/src/switches/@types/Switches.ts | 1 + libs/@guardian/libs/src/switches/README.md | 19 + .../libs/src/switches/getSwitches.test.ts | 54 + .../libs/src/switches/getSwitches.ts | 33 + libs/@guardian/libs/static/logger.svg | 103 ++ pnpm-lock.yaml | 14 +- 86 files changed, 5378 insertions(+), 46 deletions(-) delete mode 100755 libs/@guardian/libs/import create mode 100755 libs/@guardian/libs/scripts/generateSvg.logger.teams.ts create mode 100644 libs/@guardian/libs/src/@types/window.d.ts create mode 100644 libs/@guardian/libs/src/ArticleElementRole/ArticleElementRole.test.ts create mode 100644 libs/@guardian/libs/src/ArticleElementRole/ArticleElementRole.ts create mode 100644 libs/@guardian/libs/src/ArticleElementRole/README.md create mode 100644 libs/@guardian/libs/src/cookies/ERR_INVALID_COOKIE.ts create mode 100644 libs/@guardian/libs/src/cookies/README.md create mode 100644 libs/@guardian/libs/src/cookies/cookies.test.ts create mode 100644 libs/@guardian/libs/src/cookies/getCookie.ts create mode 100644 libs/@guardian/libs/src/cookies/getCookieValues.ts create mode 100644 libs/@guardian/libs/src/cookies/getDomainAttribute.ts create mode 100644 libs/@guardian/libs/src/cookies/getShortDomain.ts create mode 100644 libs/@guardian/libs/src/cookies/isValidCookie.ts create mode 100644 libs/@guardian/libs/src/cookies/memoizedCookies.ts create mode 100644 libs/@guardian/libs/src/cookies/removeCookie.ts create mode 100644 libs/@guardian/libs/src/cookies/setCookie.ts create mode 100644 libs/@guardian/libs/src/cookies/setSessionCookie.ts create mode 100644 libs/@guardian/libs/src/coreWebVitals/@types/CoreWebVitalsPayload.ts create mode 100644 libs/@guardian/libs/src/coreWebVitals/README.md create mode 100644 libs/@guardian/libs/src/coreWebVitals/index.test.ts create mode 100644 libs/@guardian/libs/src/coreWebVitals/index.ts create mode 100644 libs/@guardian/libs/src/coreWebVitals/roundWithDecimals.test.ts create mode 100644 libs/@guardian/libs/src/coreWebVitals/roundWithDecimals.ts create mode 100644 libs/@guardian/libs/src/countries/@types/Country.ts create mode 100644 libs/@guardian/libs/src/countries/@types/CountryCode.ts create mode 100644 libs/@guardian/libs/src/countries/@types/CountryKey.ts create mode 100644 libs/@guardian/libs/src/countries/README.md create mode 100644 libs/@guardian/libs/src/countries/countries.test.ts create mode 100644 libs/@guardian/libs/src/countries/countries.ts create mode 100644 libs/@guardian/libs/src/countries/getCountryByCountryCode.test.ts create mode 100644 libs/@guardian/libs/src/countries/getCountryByCountryCode.ts create mode 100644 libs/@guardian/libs/src/datetime/README.md create mode 100644 libs/@guardian/libs/src/datetime/timeAgo.test.ts create mode 100644 libs/@guardian/libs/src/datetime/timeAgo.ts create mode 100644 libs/@guardian/libs/src/format/ArticleDesign.ts create mode 100644 libs/@guardian/libs/src/format/ArticleDisplay.ts create mode 100644 libs/@guardian/libs/src/format/ArticleFormat.ts create mode 100644 libs/@guardian/libs/src/format/ArticlePillar.ts create mode 100644 libs/@guardian/libs/src/format/ArticleSpecial.ts create mode 100644 libs/@guardian/libs/src/format/ArticleTheme.ts create mode 100644 libs/@guardian/libs/src/format/README.md create mode 100644 libs/@guardian/libs/src/format/format.test.ts create mode 100644 libs/@guardian/libs/src/index.test.ts create mode 100644 libs/@guardian/libs/src/isBoolean/README.md create mode 100644 libs/@guardian/libs/src/isBoolean/isBoolean.test.ts create mode 100644 libs/@guardian/libs/src/isBoolean/isBoolean.ts create mode 100644 libs/@guardian/libs/src/isObject/README.md create mode 100644 libs/@guardian/libs/src/isObject/isObject.test.ts create mode 100644 libs/@guardian/libs/src/isObject/isObject.ts create mode 100644 libs/@guardian/libs/src/isString/README.md create mode 100644 libs/@guardian/libs/src/isString/isString.test.ts create mode 100644 libs/@guardian/libs/src/isString/isString.ts create mode 100644 libs/@guardian/libs/src/isUndefined/README.md create mode 100644 libs/@guardian/libs/src/isUndefined/isUndefined.test.ts create mode 100644 libs/@guardian/libs/src/isUndefined/isUndefined.ts create mode 100644 libs/@guardian/libs/src/joinUrl/README.md create mode 100644 libs/@guardian/libs/src/joinUrl/joinUrl.test.ts create mode 100644 libs/@guardian/libs/src/joinUrl/joinUrl.ts create mode 100644 libs/@guardian/libs/src/loadScript/README.md create mode 100644 libs/@guardian/libs/src/loadScript/loadScript.test.ts create mode 100644 libs/@guardian/libs/src/loadScript/loadScript.ts create mode 100644 libs/@guardian/libs/src/locale/README.md create mode 100644 libs/@guardian/libs/src/locale/getLocale.test.ts create mode 100644 libs/@guardian/libs/src/locale/getLocale.ts create mode 100644 libs/@guardian/libs/src/logger/@types/logger.ts create mode 100644 libs/@guardian/libs/src/logger/README.md create mode 100644 libs/@guardian/libs/src/logger/debug.ts create mode 100644 libs/@guardian/libs/src/logger/log.ts create mode 100644 libs/@guardian/libs/src/logger/logger.test.ts create mode 100644 libs/@guardian/libs/src/logger/storage-key.ts create mode 100644 libs/@guardian/libs/src/logger/teamStyles.ts create mode 100644 libs/@guardian/libs/src/ophan/@types/index.ts create mode 100644 libs/@guardian/libs/src/ophan/README.md create mode 100644 libs/@guardian/libs/src/storage/README.md create mode 100644 libs/@guardian/libs/src/storage/storage.test.ts create mode 100644 libs/@guardian/libs/src/storage/storage.ts create mode 100644 libs/@guardian/libs/src/switches/@types/Switches.ts create mode 100644 libs/@guardian/libs/src/switches/README.md create mode 100644 libs/@guardian/libs/src/switches/getSwitches.test.ts create mode 100644 libs/@guardian/libs/src/switches/getSwitches.ts create mode 100644 libs/@guardian/libs/static/logger.svg diff --git a/README.md b/README.md index c22a78ec8..2f01c480c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The following packages live in `libs/@guardian/*` and are published to NPM: - [@guardian/browserslist-config](libs/@guardian/browserslist-config) - [@guardian/eslint-config](libs/@guardian/eslint-config) - [@guardian/eslint-config-typescript](libs/@guardian/eslint-config-typescript) +- [@guardian/libs](libs/@guardian/libs) - [@guardian/prettier](libs/@guardian/prettier) - [@guardian/tsconfig](libs/@guardian/tsconfig) diff --git a/libs/@guardian/libs/import b/libs/@guardian/libs/import deleted file mode 100755 index cab846382..000000000 --- a/libs/@guardian/libs/import +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash - -# import @guardian/libs from current repo - -tmp=.temp-import - -# create temp dir to download @guardian/libs repo into -mkdir -p $tmp - -# fetch the @guardian/libs repo -curl -L -O --output-dir $tmp https://github.com/guardian/libs/archive/main.tar.gz - -# extract the files we want into our package dir -tar -xvf $tmp/main.tar.gz \ - --directory libs/@guardian/libs \ - --strip-components 1 \ - libs-main/src \ - libs-main/scripts/generateSvg.logger.teams.ts \ - libs-main/static \ - libs-main/README.md - -# remove the temp dir -rm -rf "$tmp" - -# remove the aliased version -pnpm remove original-libs --filter @guardian/libs - diff --git a/libs/@guardian/libs/package.json b/libs/@guardian/libs/package.json index 6c8a01c55..64e2e246f 100644 --- a/libs/@guardian/libs/package.json +++ b/libs/@guardian/libs/package.json @@ -1,12 +1,9 @@ { "name": "@guardian/libs", - "version": "8.0.1", - "private": true, + "version": "8.0.0", + "private": false, "description": "A collection of JavaScript libraries and TypeScript types for Guardian projects", "sideEffects": false, - "dependencies": { - "original-libs": "npm:@guardian/libs@8.0.0" - }, "devDependencies": { "@types/wcag-contrast": "3.0.0", "jest-fetch-mock": "3.0.3", diff --git a/libs/@guardian/libs/scripts/generateSvg.logger.teams.ts b/libs/@guardian/libs/scripts/generateSvg.logger.teams.ts new file mode 100755 index 000000000..463721568 --- /dev/null +++ b/libs/@guardian/libs/scripts/generateSvg.logger.teams.ts @@ -0,0 +1,89 @@ +import fs from 'fs'; +import { teamStyles } from '../src/logger/teamStyles'; + +fs.writeFileSync(__dirname + '/../static/logger.svg', generateSvg()); + +function generateSvg(): string { + const filteredTeams = Object.entries(teamStyles).filter((team) => { + const [name] = team; + return name !== 'common'; + }); + + const padding = 10; + const lineHeight = 24; + const width = 600; + const height = filteredTeams.length * lineHeight + padding * 2 + 60; + + const lines = filteredTeams.map((team, index) => { + const [name, colours] = team; + return `
+ @guardian + ${name} + message no.${index} + + console.log +
`; + }); + const svg = ` + + +
+
+
Console
+ ${lines.join('')} +
+
+
+
`; + return svg; +} diff --git a/libs/@guardian/libs/src/@types/window.d.ts b/libs/@guardian/libs/src/@types/window.d.ts new file mode 100644 index 000000000..5e69e0d3f --- /dev/null +++ b/libs/@guardian/libs/src/@types/window.d.ts @@ -0,0 +1,20 @@ +import type { TeamSubscription } from '../logger/@types/logger'; +import type { Switches } from '../switches/@types/Switches'; + +declare global { + interface Window { + guardian?: { + logger?: { + subscribeTo: TeamSubscription; + unsubscribeFrom: TeamSubscription; + teams: () => string[]; + }; + config?: { + page?: { + isPreview: boolean; + }; + switches?: Switches; + }; + }; + } +} diff --git a/libs/@guardian/libs/src/ArticleElementRole/ArticleElementRole.test.ts b/libs/@guardian/libs/src/ArticleElementRole/ArticleElementRole.test.ts new file mode 100644 index 000000000..e82e3ee1b --- /dev/null +++ b/libs/@guardian/libs/src/ArticleElementRole/ArticleElementRole.test.ts @@ -0,0 +1,5 @@ +import { ArticleElementRole } from './ArticleElementRole'; + +it('ArticleElementRole enum contains Standard', () => { + expect(ArticleElementRole.Standard).toBeDefined(); +}); diff --git a/libs/@guardian/libs/src/ArticleElementRole/ArticleElementRole.ts b/libs/@guardian/libs/src/ArticleElementRole/ArticleElementRole.ts new file mode 100644 index 000000000..540d2be25 --- /dev/null +++ b/libs/@guardian/libs/src/ArticleElementRole/ArticleElementRole.ts @@ -0,0 +1,15 @@ +// ----- Types ----- // + +enum ArticleElementRole { + Standard, + Immersive, + Supporting, + Showcase, + Inline, + Thumbnail, + HalfWidth, +} + +// ----- Exports ----- // + +export { ArticleElementRole }; diff --git a/libs/@guardian/libs/src/ArticleElementRole/README.md b/libs/@guardian/libs/src/ArticleElementRole/README.md new file mode 100644 index 000000000..39b23a214 --- /dev/null +++ b/libs/@guardian/libs/src/ArticleElementRole/README.md @@ -0,0 +1,11 @@ +# `ArticleElementRole` + +ArticleElementRole is a semantic/layout-oriented property that's applied to elements in an article. It describes the "role" of that element in the piece and how it should be laid out within the page. Consider the example of an image: is the image filling a "supporting" role? is it just a thumbnail? is it "showcasing" something? + +Whilst most commonly used for images, it can also apply to atoms and embeds. + +## Usage + +```js +import { ArticleElementRole } from '@guardian/libs'; +``` diff --git a/libs/@guardian/libs/src/cookies/ERR_INVALID_COOKIE.ts b/libs/@guardian/libs/src/cookies/ERR_INVALID_COOKIE.ts new file mode 100644 index 000000000..162c7c75d --- /dev/null +++ b/libs/@guardian/libs/src/cookies/ERR_INVALID_COOKIE.ts @@ -0,0 +1 @@ +export const ERR_INVALID_COOKIE = `Cookie must not contain invalid characters (space, tab and the following characters: '()<>@,;"/[]?={}')`; diff --git a/libs/@guardian/libs/src/cookies/README.md b/libs/@guardian/libs/src/cookies/README.md new file mode 100644 index 000000000..5a360366c --- /dev/null +++ b/libs/@guardian/libs/src/cookies/README.md @@ -0,0 +1,132 @@ +# Cookies + +Robust API over `document.cookie`. + +### Usage + +```js +import { + getCookie, + removeCookie, + setCookie, + setSessionCookie + } from '@guardian/libs'; +``` + +## Methods + +- [`setCookie({name, value, daysToLive?, isCrossSubdomain?})`](#setCookie) +- [`setSessionCookie({name, value})`](#setSessionCookie) +- [`getCookie({name, shouldMemoize?})`](#getCookie) +- [`removeCookie(name)`](#removeCookie) + +## `setCookie({name, value, daysToLive?, isCrossSubdomain?})` + +Returns: `void` + +Sets a cookie taking a config object with name and value, optional daysToLive and optional isCrossSubdomain flag. + +#### `name` + +Type: `string` + +Name of the cookie. + +#### `value` + +Type: `string`
+ +Value of the cookie. + +#### `daysToLive?` + +Type: `number` + +Days you would like this cookie to live for. + +#### `isCrossSubdomain?` + +Type: `boolean`
+ +Set this true if the cookie is cross subdomain. + +### Example + +```js +setCookie({name:'GU_country_code', value:'GB'}) +setCookie({name:'GU_country_code', value:'GB', daysToLive: 7}) +setCookie({name:'GU_country_code', value:'GB', daysToLive: 7, isCrossSubdomain: true}) +``` + +## `setSessionCookie({name, value})` + +Returns: `void` + +Sets a session cookie (no expiry date) taking a config object with name and value. + +#### `name` + +Type: `string` + +Name of the cookie. + +#### `value` + +Type: `string`
+ +Value of the cookie. + +### Example + +```js +setSessionCookie({name:'GU_country_code', value: 'GB'}) +``` + +## `getCookie({name, shouldMemoize?})` + +Returns: `cookie` value if it exists or `null`. Takes a config object with name and shouldMemoize params + +#### `name` + +Type: `string` + +Name of the cookie to retrieve. + + +#### `shouldMemoize?` + +Type: `boolean`
+ +When this is set to true it will keep the cookie in memory to avoid fetching more than once. + + +### Example + +```js +getCookie({name:'GU_geo_country'}); //GB +getCookie({name:'GU_geo_country', shouldMemoize: true}); //GB +``` + +## `removeCookie({name, currentDomainOnly?})` + +Returns: `void` + +Removes a cookie. + +#### `names` + +Type: `string` + +Name of the stored cookie to remove. + +#### `currentDomainOnly` + +Type: `boolean` + +Set to true if it's a cookie for current domain only, defaults to false +### Example + +```js +removeCookie({name:'GU_geo_country'}); +removeCookie({name:'GU_geo_country', currentDomainOnly: true}); +``` diff --git a/libs/@guardian/libs/src/cookies/cookies.test.ts b/libs/@guardian/libs/src/cookies/cookies.test.ts new file mode 100644 index 000000000..5b5ac25f8 --- /dev/null +++ b/libs/@guardian/libs/src/cookies/cookies.test.ts @@ -0,0 +1,311 @@ +import MockDate from 'mockdate'; +import { getCookie } from './getCookie'; +import * as getCookieValues from './getCookieValues'; +import { removeCookie } from './removeCookie'; +import { setCookie } from './setCookie'; +import { setSessionCookie } from './setSessionCookie'; + +describe('cookies', () => { + let cookieValue = ''; + + beforeAll(() => { + Object.defineProperty(document, 'cookie', { + get() { + return cookieValue + .replace('|', ';') + .replace(/^[;|]|[;|]$/g, ''); + }, + + set(value: string) { + const name = value.split('=')[0]; + const newVal = cookieValue + .split('|') + .filter((cookie) => cookie.split('=')[0] !== name); + + newVal.push(value); + cookieValue = newVal.join('|'); + }, + }); + }); + + beforeEach(() => { + cookieValue = ''; + Object.defineProperty(document, 'domain', { + value: 'www.theguardian.com', + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + MockDate.reset(); + }); + + it('gets a cookie', () => { + document.cookie = + 'optimizelyEndUserId=oeu1398171767331r0.5280374749563634; __qca=P0-938012256-1398171768649;'; + expect(getCookie({ name: '__qca' })).toEqual( + 'P0-938012256-1398171768649', + ); + }); + + it('sets a cookie with an expiry date in six months that preserves UTC time', () => { + MockDate.set('Sun Nov 17 2019 12:00:00 GMT+0000 (Greenwich Mean Time)'); + setCookie({ + name: 'cookie-1-name', + value: 'cookie-1-value', + }); + expect(document.cookie).toMatch( + new RegExp( + 'cookie-1-name=cookie-1-value; path=/; expires=Wed, 01 Apr 2020 12:00:00 GMT; domain=.theguardian.com', + ), + ); + }); + + it('sets a cookie to expire in a specific number of days', () => { + MockDate.set('Sun Nov 17 2019 12:00:00 GMT+0000 (Greenwich Mean Time)'); + setCookie({ + name: 'cookie-1-name', + value: 'cookie-1-value', + daysToLive: 7, + }); + expect(document.cookie).toEqual( + 'cookie-1-name=cookie-1-value; path=/; expires=Sun, 24 Nov 2019 12:00:00 GMT; domain=.theguardian.com', + ); + }); + + it('sets a cookie to expire in a specific number of days that preserves UTC time', () => { + // BST started Sun 28th Mar 2021 + MockDate.set('Sat Mar 27 2021 12:00:00 GMT+0000 (Greenwich Mean Time)'); + setCookie({ + name: 'cookie-1-name', + value: 'cookie-1-value', + daysToLive: 7, + }); + expect(document.cookie).toEqual( + 'cookie-1-name=cookie-1-value; path=/; expires=Sat, 03 Apr 2021 12:00:00 GMT; domain=.theguardian.com', + ); + }); + + it('does not set a cookie when the cookie name is invalid', () => { + expect(() => + setCookie({ + name: 'cookie-1-name-@', + value: 'cookie-1-value', + }), + ).toThrowError( + `Cookie must not contain invalid characters (space, tab and the following characters: '()<>@,;"/[]?={}') cookie-1-name-@=cookie-1-value`, + ); + expect(document.cookie).toEqual(''); + }); + + it('does not set a cookie when the cookie value is invalid', () => { + expect(() => + setCookie({ + name: 'cookie-1-name', + value: 'cookie-1-value-<', + }), + ).toThrowError( + `Cookie must not contain invalid characters (space, tab and the following characters: '()<>@,;"/[]?={}') cookie-1-name=cookie-1-value-<`, + ); + expect(document.cookie).toEqual(''); + }); + + it('sets a session cookie', () => { + setSessionCookie({ + name: 'cookie-1-name', + value: 'cookie-1-value', + }); + expect(document.cookie).toEqual( + 'cookie-1-name=cookie-1-value; path=/; domain=.theguardian.com', + ); + }); + + it('sets a session cookie for localhost', () => { + Object.defineProperty(document, 'domain', { + value: 'localhost', + }); + expect(document.cookie).toEqual(''); + setSessionCookie({ + name: 'cookie-1-name', + value: 'cookie-1-value', + }); + expect(document.cookie).toEqual('cookie-1-name=cookie-1-value; path=/'); + }); + + it('does not set a session cookie when the cookie name is invalid', () => { + expect(() => + setSessionCookie({ + name: 'cookie-1-name-@', + value: 'cookie-1-value', + }), + ).toThrowError( + `Cookie must not contain invalid characters (space, tab and the following characters: '()<>@,;"/[]?={}') cookie-1-name-@=cookie-1-value`, + ); + expect(document.cookie).toEqual(''); + }); + + it('does not set a cookie when the cookie value is invalid', () => { + expect(() => + setSessionCookie({ + name: 'cookie-1-name', + value: 'cookie-1-value-<', + }), + ).toThrowError( + `Cookie must not contain invalid characters (space, tab and the following characters: '()<>@,;"/[]?={}') cookie-1-name=cookie-1-value-<`, + ); + expect(document.cookie).toEqual(''); + }); + + it('gets a memoized cookie with days to live and cross subdomain', () => { + setCookie({ + name: 'GU_geo_country', + value: 'GB', + daysToLive: 1, + isCrossSubdomain: true, + }); + const spy = jest.spyOn(getCookieValues, 'getCookieValues'); + expect( + getCookie({ + name: 'GU_geo_country', + shouldMemoize: true, + }), + ).toEqual('GB'); + expect( + getCookie({ + name: 'GU_geo_country', + shouldMemoize: true, + }), + ).toEqual('GB'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('gets a memoized cookie with days to live', () => { + setCookie({ + name: 'GU_geo_country', + value: 'IT', + daysToLive: 1, + }); + const spy = jest.spyOn(getCookieValues, 'getCookieValues'); + expect( + getCookie({ + name: 'GU_geo_country', + shouldMemoize: true, + }), + ).toEqual('IT'); + expect( + getCookie({ + name: 'GU_geo_country', + shouldMemoize: true, + }), + ).toEqual('IT'); + expect( + getCookie({ + name: 'GU_geo_country', + shouldMemoize: true, + }), + ).toEqual('IT'); + // for some reason the spy is been called 1 additional time although that's not happening in reality + expect(spy).not.toHaveBeenCalledTimes(2); + }); + + it('re-sets a memoized cookie', () => { + setCookie({ + name: 'GU_geo_country', + value: 'GB', + daysToLive: 3, + isCrossSubdomain: false, + }); + expect( + getCookie({ + name: 'GU_geo_country', + shouldMemoize: true, + }), + ).toEqual('GB'); + setCookie({ + name: 'GU_geo_country', + value: 'IT', + daysToLive: 3, + isCrossSubdomain: false, + }); + expect( + getCookie({ + name: 'GU_geo_country', + shouldMemoize: true, + }), + ).toEqual('IT'); + }); + + it('re-sets a memoized session cookie', () => { + setSessionCookie({ + name: 'GU_geo_country', + value: 'GB', + }); + expect( + getCookie({ + name: 'GU_geo_country', + shouldMemoize: true, + }), + ).toEqual('GB'); + setSessionCookie({ + name: 'GU_geo_country', + value: 'GR', + }); + expect( + getCookie({ + name: 'GU_geo_country', + shouldMemoize: true, + }), + ).toEqual('GR'); + }); + + it('removes a cookie and sets a short domain', () => { + document.cookie = 'cookie-1-name=cookie-1-value'; + + removeCookie({ name: 'cookie-1-name' }); + + const { cookie } = document; + + expect(cookie).toMatch( + new RegExp( + 'cookie-1-name=;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT; domain=.theguardian.com', + ), + ); + }); + + it('removes a cookie and does not set a short domain for localhost', () => { + Object.defineProperty(document, 'domain', { + value: 'localhost', + }); + + document.cookie = 'cookie-1-name=cookie-1-value'; + + removeCookie({ name: 'cookie-1-name' }); + + const { cookie } = document; + + expect(cookie).toMatch( + new RegExp( + 'cookie-1-name=;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT; domain=localhost', + ), + ); + }); + + it('removes a cookie and does not set a short domain for preview', () => { + window.guardian = { + config: { page: { isPreview: true } }, + }; + + document.cookie = 'cookie-1-name=cookie-1-value'; + + removeCookie({ name: 'cookie-1-name' }); + + const { cookie } = document; + + expect(cookie).toMatch( + new RegExp( + 'cookie-1-name=;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT; domain=www.theguardian.com', + ), + ); + }); +}); diff --git a/libs/@guardian/libs/src/cookies/getCookie.ts b/libs/@guardian/libs/src/cookies/getCookie.ts new file mode 100644 index 000000000..874433c21 --- /dev/null +++ b/libs/@guardian/libs/src/cookies/getCookie.ts @@ -0,0 +1,30 @@ +import { getCookieValues } from './getCookieValues'; +import { memoizedCookies } from './memoizedCookies'; + +/** + * Return a cookie. If it's been memoized it won't retrieve it again. + * @param details Details about the cookie. + * @param details.name - the cookie’s name. + * @param details.shouldMemoize - set to true if you want to memoize it, default false. + */ + +export const getCookie = ({ + name, + shouldMemoize = false, +}: { + name: string; + shouldMemoize?: boolean; +}): string | null => { + const memoizedCookie = memoizedCookies.get(name); + if (memoizedCookie) return memoizedCookie; + + const [value] = getCookieValues(name); + + if (value) { + if (shouldMemoize) { + memoizedCookies.set(name, value); + } + return value; + } + return null; +}; diff --git a/libs/@guardian/libs/src/cookies/getCookieValues.ts b/libs/@guardian/libs/src/cookies/getCookieValues.ts new file mode 100644 index 000000000..62fd27054 --- /dev/null +++ b/libs/@guardian/libs/src/cookies/getCookieValues.ts @@ -0,0 +1,15 @@ +export const getCookieValues = (name: string): string[] => { + const nameEq = `${name}=`; + const cookies = document.cookie.split(';'); + + return cookies.reduce((acc: string[], cookie: string) => { + const cookieTrimmed: string = cookie.trim(); + if (cookieTrimmed.startsWith(nameEq)) { + acc.push( + cookieTrimmed.substring(nameEq.length, cookieTrimmed.length), + ); + } + + return acc; + }, []); +}; diff --git a/libs/@guardian/libs/src/cookies/getDomainAttribute.ts b/libs/@guardian/libs/src/cookies/getDomainAttribute.ts new file mode 100644 index 000000000..9a3413820 --- /dev/null +++ b/libs/@guardian/libs/src/cookies/getDomainAttribute.ts @@ -0,0 +1,6 @@ +import { getShortDomain } from './getShortDomain'; + +export const getDomainAttribute = ({ isCrossSubdomain = false } = {}) => { + const shortDomain = getShortDomain({ isCrossSubdomain }); + return shortDomain === 'localhost' ? '' : ` domain=${shortDomain};`; +}; diff --git a/libs/@guardian/libs/src/cookies/getShortDomain.ts b/libs/@guardian/libs/src/cookies/getShortDomain.ts new file mode 100644 index 000000000..586cc30b3 --- /dev/null +++ b/libs/@guardian/libs/src/cookies/getShortDomain.ts @@ -0,0 +1,14 @@ +export const getShortDomain = ({ isCrossSubdomain = false } = {}) => { + const domain = document.domain || ''; + + if (domain === 'localhost' || window.guardian?.config?.page?.isPreview) { + return domain; + } + + // Trim any possible subdomain (will be shared with supporter, identity, etc) + if (isCrossSubdomain) { + return ['', ...domain.split('.').slice(-2)].join('.'); + } + // Trim subdomains for prod (www.theguardian), code (m.code.dev-theguardian) and dev (dev.theguardian, m.thegulocal) + return domain.replace(/^(www|m\.code|dev|m)\./, '.'); +}; diff --git a/libs/@guardian/libs/src/cookies/isValidCookie.ts b/libs/@guardian/libs/src/cookies/isValidCookie.ts new file mode 100644 index 000000000..019bc4efb --- /dev/null +++ b/libs/@guardian/libs/src/cookies/isValidCookie.ts @@ -0,0 +1,7 @@ +const COOKIE_REGEX = /[()<>@,;"\\/[\]?={} \t]/g; + +// subset of https://github.com/guzzle/guzzle/pull/1131 +const isValidCookieValue = (name: string) => !COOKIE_REGEX.test(name); + +export const isValidCookie = (name: string, value: string): boolean => + isValidCookieValue(name) && isValidCookieValue(value); diff --git a/libs/@guardian/libs/src/cookies/memoizedCookies.ts b/libs/@guardian/libs/src/cookies/memoizedCookies.ts new file mode 100644 index 000000000..edf2bec35 --- /dev/null +++ b/libs/@guardian/libs/src/cookies/memoizedCookies.ts @@ -0,0 +1 @@ +export const memoizedCookies: Map = new Map(); diff --git a/libs/@guardian/libs/src/cookies/removeCookie.ts b/libs/@guardian/libs/src/cookies/removeCookie.ts new file mode 100644 index 000000000..df136f9c5 --- /dev/null +++ b/libs/@guardian/libs/src/cookies/removeCookie.ts @@ -0,0 +1,26 @@ +import { getShortDomain } from './getShortDomain'; + +/** + * Removes a cookie. + * @param details Details about the cookie. + * @param details.name - the cookie’s name. + * @param details.currentDomainOnly - set to true if it's only for current domain + */ + +export const removeCookie = ({ + name, + currentDomainOnly = false, +}: { + name: string; + currentDomainOnly?: boolean; +}): void => { + const expires = 'expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + const path = 'path=/;'; + + // Remove cookie, implicitly using the document's domain. + document.cookie = `${name}=;${path}${expires}`; + if (!currentDomainOnly) { + // also remove from the short domain + document.cookie = `${name}=;${path}${expires} domain=${getShortDomain()};`; + } +}; diff --git a/libs/@guardian/libs/src/cookies/setCookie.ts b/libs/@guardian/libs/src/cookies/setCookie.ts new file mode 100644 index 000000000..d2d3a42bb --- /dev/null +++ b/libs/@guardian/libs/src/cookies/setCookie.ts @@ -0,0 +1,53 @@ +import { ERR_INVALID_COOKIE } from './ERR_INVALID_COOKIE'; +import { getCookieValues } from './getCookieValues'; +import { getDomainAttribute } from './getDomainAttribute'; +import { isValidCookie } from './isValidCookie'; +import { memoizedCookies } from './memoizedCookies'; + +/** + * Set a cookie. If it's been memoized it will replace it's memoized value + * @param details Details about the cookie. + * @param details.name - the cookie’s name. + * @param details.value - the cookie’s value. + * @param details.daysToLive - optional expiry date will be calculated based on the daysToLive + * @param details.isCrossSubdomain - specify if it's a cross subdomain cookie, default false + */ + +export const setCookie = ({ + name, + value, + daysToLive, + isCrossSubdomain = false, +}: { + name: string; + value: string; + daysToLive?: number; + isCrossSubdomain?: boolean; +}): void => { + const expires = new Date(); + + if (!isValidCookie(name, value)) { + throw new Error(`${ERR_INVALID_COOKIE} ${name}=${value}`); + } + + if (daysToLive) { + expires.setUTCDate(expires.getUTCDate() + daysToLive); + } else { + expires.setUTCMonth(expires.getUTCMonth() + 5); + expires.setUTCDate(1); + } + + document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()};${getDomainAttribute( + { + isCrossSubdomain, + }, + )}`; + + // If the cookie is already memoized we want to replace its value + if (memoizedCookies.has(name)) { + const [value] = getCookieValues(name); + if (value) { + memoizedCookies.set(name, value); + } + } +}; diff --git a/libs/@guardian/libs/src/cookies/setSessionCookie.ts b/libs/@guardian/libs/src/cookies/setSessionCookie.ts new file mode 100644 index 000000000..5a97d4ebc --- /dev/null +++ b/libs/@guardian/libs/src/cookies/setSessionCookie.ts @@ -0,0 +1,34 @@ +import { ERR_INVALID_COOKIE } from './ERR_INVALID_COOKIE'; +import { getCookieValues } from './getCookieValues'; +import { getDomainAttribute } from './getDomainAttribute'; +import { isValidCookie } from './isValidCookie'; +import { memoizedCookies } from './memoizedCookies'; + +/** + * Set a session cookie. If it's been memoized it will replace memoized value + * @param details Details about the cookie. + * @param details.name - the cookie’s name. + * @param details.value - the cookie’s value. + */ + +export const setSessionCookie = ({ + name, + value, +}: { + name: string; + value: string; +}): void => { + if (!isValidCookie(name, value)) { + throw new Error(`${ERR_INVALID_COOKIE} ${name}=${value}`); + } + + document.cookie = `${name}=${value}; path=/;${getDomainAttribute()}`; + + // If the cookie is already memoized we want to replace its value + if (memoizedCookies.has(name)) { + const [value] = getCookieValues(name); + if (value) { + memoizedCookies.set(name, value); + } + } +}; diff --git a/libs/@guardian/libs/src/coreWebVitals/@types/CoreWebVitalsPayload.ts b/libs/@guardian/libs/src/coreWebVitals/@types/CoreWebVitalsPayload.ts new file mode 100644 index 000000000..15364bd57 --- /dev/null +++ b/libs/@guardian/libs/src/coreWebVitals/@types/CoreWebVitalsPayload.ts @@ -0,0 +1,9 @@ +export type CoreWebVitalsPayload = { + page_view_id: string | null; + browser_id: string | null; + fid: null | number; + cls: null | number; + lcp: null | number; + fcp: null | number; + ttfb: null | number; +}; diff --git a/libs/@guardian/libs/src/coreWebVitals/README.md b/libs/@guardian/libs/src/coreWebVitals/README.md new file mode 100644 index 000000000..1771bfbd5 --- /dev/null +++ b/libs/@guardian/libs/src/coreWebVitals/README.md @@ -0,0 +1,108 @@ +# Core Web Vitals + +Reports on Core Web Vitals using Google’s [`web-vitals`] library, and send the +metrics to an logging endpoint when the user leaves the page. + +By default, a sampling rate is set at 1% for which Core Web Vitals will be +gathered and sent. It is possible to set this sampling to a different value +as initialisation or bypass it asynchronously. + +[`web-vitals`]: https://github.com/GoogleChrome/web-vitals + +## Usage + +```js +import { initCoreWebVitals, getCookie } from '@guardian/libs'; + +// browserId & pageViewId are needed to join up the data downstream. +const init: InitCoreWebVitalsOptions = { + browserId : getCookie({ name: 'bwid', shouldMemoize: true}), + pageViewId: guardian.config.ophan.pageViewId, + + // Whether to use CODE or PROD endpoints. + isDev: window.location.hostname !== 'www.theguardian.com', +} + +initCoreWebVitals(init) +``` + +### `init.sampling` + +Sets a sampling rate for which to send data to the logging endpoint. + +Defaults to `1 / 100`. + +```ts +const init: InitCoreWebVitalsOptions = { + isDev: false, + + // Send data for 20% of page views. Inform Data Tech team about expected + // spikes in data ingestion + sampling: 20 / 100, +} + +initCoreWebVitals(init) +``` + +### `init.team` + +Optional team name to log whether the payload has been successfully queued for +transfer. + +```ts +const init: InitCoreWebVitalsOptions = { + isDev: false, + sampling: 100 / 100, + team: 'dotcom', +} + +initCoreWebVitals(init) + +// should call log('dotcom', 'Core Web Vitals payload successfully queued […]') +``` + +### `bypassCoreWebVitalsSampling` + +Allows to asynchronously bypass the sampling rate. + +Takes an optional team name for which to print logs for. + +```ts +/* … after having called initCoreWebVitals() … */ + +addEventListener('some-event', () => { + // CWV will be sent for all page views where `some-event` was triggered + bypassCoreWebVitalsSampling(); +}) +``` + + +## Types + +### `CoreWebVitalsPayload` + +```ts +type CoreWebVitalsPayload = { + page_view_id: string | null; + browser_id: string | null; + fid: null | number; + cls: null | number; + lcp: null | number; + fcp: null | number; + ttfb: null | number; +}; +``` + +### `InitCoreWebVitalsOptions` + +```ts +type InitCoreWebVitalsOptions = { + isDev: boolean; + + browserId?: string | null; + pageViewId?: string | null; + + sampling?: number; + team?: TeamName; +}; +``` diff --git a/libs/@guardian/libs/src/coreWebVitals/index.test.ts b/libs/@guardian/libs/src/coreWebVitals/index.test.ts new file mode 100644 index 000000000..624415c9b --- /dev/null +++ b/libs/@guardian/libs/src/coreWebVitals/index.test.ts @@ -0,0 +1,385 @@ +import type { Metric, ReportHandler } from 'web-vitals'; +import * as logger from '../logger/log'; +import type { CoreWebVitalsPayload } from './@types/CoreWebVitalsPayload'; +import { _, bypassCoreWebVitalsSampling, initCoreWebVitals } from './index'; + +const { coreWebVitalsPayload, reset } = _; + +const defaultCoreWebVitalsPayload: CoreWebVitalsPayload = { + page_view_id: '123456', + browser_id: 'abcdef', + fid: 50.5, + fcp: 100.1, + lcp: 150, + ttfb: 9.99, + cls: 0.01, +}; + +const browserId = defaultCoreWebVitalsPayload.browser_id; +const pageViewId = defaultCoreWebVitalsPayload.page_view_id; + +jest.mock('web-vitals', () => ({ + getTTFB: (onReport: ReportHandler) => { + onReport({ + value: defaultCoreWebVitalsPayload.ttfb, + name: 'TTFB', + } as Metric); + }, + getFCP: (onReport: ReportHandler) => { + onReport({ + value: defaultCoreWebVitalsPayload.fcp, + name: 'FCP', + } as Metric); + }, + getCLS: (onReport: ReportHandler) => { + onReport({ + value: defaultCoreWebVitalsPayload.cls, + name: 'CLS', + } as Metric); + }, + getFID: (onReport: ReportHandler) => { + onReport({ + value: defaultCoreWebVitalsPayload.fid, + name: 'FID', + } as Metric); + }, + getLCP: (onReport: ReportHandler) => { + onReport({ + value: defaultCoreWebVitalsPayload.lcp, + name: 'LCP', + } as Metric); + }, +})); + +const mockBeacon = jest.fn().mockReturnValue(true); +navigator.sendBeacon = mockBeacon; + +const mockConsoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => void 0); + +const spyLog = jest.spyOn(logger, 'log'); + +const setVisibilityState = (value: VisibilityState = 'visible') => { + Object.defineProperty(document, 'visibilityState', { + writable: true, + configurable: true, + value, + }); +}; + +describe('coreWebVitals', () => { + beforeEach(() => { + reset(); + }); + + afterAll(() => { + setVisibilityState(); + }); + + it('sends a beacon when sampling is 100%', async () => { + const mockAddEventListener = jest.spyOn(global, 'addEventListener'); + + const sampling = 100 / 100; + await initCoreWebVitals({ + browserId, + pageViewId, + isDev: true, + sampling, + }); + + expect(mockAddEventListener).toHaveBeenCalledTimes(2); + + setVisibilityState('hidden'); + global.dispatchEvent(new Event('visibilitychange')); + global.dispatchEvent(new Event('pagehide')); + + expect(mockBeacon).toHaveBeenCalledTimes(1); + }); + + it('does not run web-vitals if sampling is 0%', async () => { + const sampling = 0 / 100; + await initCoreWebVitals({ + browserId, + pageViewId, + isDev: true, + sampling, + }); + + setVisibilityState('hidden'); + global.dispatchEvent(new Event('visibilitychange')); + global.dispatchEvent(new Event('pagehide')); + + expect(mockBeacon).toHaveBeenCalledTimes(0); + expect(coreWebVitalsPayload).toMatchObject({ + fid: null, + fcp: null, + lcp: null, + ttfb: null, + cls: null, + }); + }); + + it('sends a beacon if sampling at 0% but bypassed via hash', async () => { + window.location.hash = '#bypassCoreWebVitalsSampling'; + const sampling = 0 / 100; + await initCoreWebVitals({ + browserId, + pageViewId, + isDev: true, + sampling, + }); + window.location.hash = ''; + + global.dispatchEvent(new Event('pagehide')); + + expect(mockBeacon).toHaveBeenCalledTimes(1); + }); + + it('sends a beacon if sampling at 0% but bypassed asynchronously', async () => { + const sampling = 0 / 100; + await initCoreWebVitals({ + browserId, + pageViewId, + isDev: true, + sampling, + }); + + expect(mockBeacon).not.toHaveBeenCalled(); + + await bypassCoreWebVitalsSampling(); + + global.dispatchEvent(new Event('pagehide')); + + expect(mockBeacon).toHaveBeenCalledTimes(1); + }); + + it('only registers pagehide if document is visible', async () => { + await initCoreWebVitals({ + browserId, + pageViewId, + isDev: true, + sampling: 1, + }); + + setVisibilityState('visible'); + global.dispatchEvent(new Event('visibilitychange')); + + expect(mockBeacon).not.toHaveBeenCalled(); + }); +}); + +describe('Warnings', () => { + beforeEach(() => { + reset(); + }); + + it('should warn if already initialised', async () => { + await initCoreWebVitals({ pageViewId, browserId, isDev: true }); + await initCoreWebVitals({ pageViewId, browserId, isDev: true }); + + expect(mockConsoleWarn).toHaveBeenCalledWith( + 'initCoreWebVitals already initialised', + expect.any(String), + ); + }); + + it('expect to be initialised before calling bypassCoreWebVitalsSampling', async () => { + await bypassCoreWebVitalsSampling(); + + expect(mockConsoleWarn).toHaveBeenCalledWith( + 'initCoreWebVitals not yet initialised', + ); + + global.dispatchEvent(new Event('pagehide')); + expect(mockBeacon).not.toHaveBeenCalled(); + }); + + it('should warn if browserId is missing', async () => { + await initCoreWebVitals({ pageViewId, isDev: true }); + + expect(mockConsoleWarn).toHaveBeenCalledWith( + 'browserId or pageViewId missing from Core Web Vitals.', + expect.any(String), + expect.objectContaining({ browserId: null }), + ); + }); + + it('should warn if pageViewId is missing', async () => { + await initCoreWebVitals({ browserId, isDev: true }); + + expect(mockConsoleWarn).toHaveBeenCalledWith( + 'browserId or pageViewId missing from Core Web Vitals.', + expect.any(String), + expect.objectContaining({ pageViewId: null }), + ); + }); + + it('should warn if sampling is below 0', async () => { + await initCoreWebVitals({ + browserId, + pageViewId, + isDev: true, + sampling: -0.1, + }); + + expect(mockConsoleWarn).toHaveBeenCalledWith( + 'Core Web Vitals sampling is outside the 0 to 1 range: ', + -0.1, + ); + }); + + it('should warn if sampling is above 1', async () => { + await initCoreWebVitals({ + browserId, + pageViewId, + isDev: true, + sampling: 1.1, + }); + + expect(mockConsoleWarn).toHaveBeenCalledWith( + 'Core Web Vitals sampling is outside the 0 to 1 range: ', + 1.1, + ); + }); + + it('should warn if sampling is above at 0%', async () => { + await initCoreWebVitals({ + browserId, + pageViewId, + isDev: true, + sampling: 0, + }); + + expect(mockConsoleWarn).toHaveBeenCalledWith( + 'Core Web Vitals are sampled at 0%', + ); + }); + + it('should warn if sampling is above at 100%', async () => { + await initCoreWebVitals({ + browserId, + pageViewId, + isDev: true, + sampling: 1, + }); + + expect(mockConsoleWarn).toHaveBeenCalledWith( + 'Core Web Vitals are sampled at 100%', + ); + }); +}); + +describe('Endpoints', () => { + beforeEach(() => { + reset(); + }); + + it('should use CODE URL if isDev', async () => { + const isDev = true; + await initCoreWebVitals({ browserId, pageViewId, isDev, sampling: 1 }); + + global.dispatchEvent(new Event('pagehide')); + + expect(mockBeacon).toHaveBeenCalledWith( + _.Endpoints.CODE, + expect.any(String), + ); + }); + + it('should use PROD URL if isDev is false', async () => { + const isDev = false; + await initCoreWebVitals({ browserId, pageViewId, isDev, sampling: 1 }); + + global.dispatchEvent(new Event('pagehide')); + + expect(mockBeacon).toHaveBeenCalledWith( + _.Endpoints.PROD, + expect.any(String), + ); + }); +}); + +describe('Logging', () => { + beforeEach(() => { + reset(); + setVisibilityState(); + }); + + it('should log for every team that registered', async () => { + const isDev = true; + await initCoreWebVitals({ + browserId, + pageViewId, + isDev, + team: 'dotcom', + }); + await bypassCoreWebVitalsSampling('design'); + await bypassCoreWebVitalsSampling('commercial'); + + setVisibilityState('hidden'); + global.dispatchEvent(new Event('visibilitychange')); + + expect(spyLog).toHaveBeenCalledTimes(3); + expect(spyLog).nthCalledWith( + 1, + 'dotcom', + expect.stringContaining('successfully'), + ); + expect(spyLog).nthCalledWith( + 2, + 'design', + expect.stringContaining('successfully'), + ); + expect(spyLog).nthCalledWith( + 3, + 'commercial', + expect.stringContaining('successfully'), + ); + }); + + it('should log a failure if it happens', async () => { + const mockAddEventListener = jest.spyOn(global, 'addEventListener'); + const isDev = true; + const sampling = 100 / 100; + await initCoreWebVitals({ + browserId, + pageViewId, + isDev, + sampling, + team: 'dotcom', + }); + + mockBeacon.mockReturnValueOnce(false); + + setVisibilityState('hidden'); + global.dispatchEvent(new Event('visibilitychange')); + + expect(mockAddEventListener).toHaveBeenCalledTimes(2); + + expect(spyLog).toHaveBeenCalledTimes(1); + expect(spyLog).toHaveBeenLastCalledWith( + 'dotcom', + expect.stringContaining('Failed to queue'), + ); + }); +}); + +describe('web-vitals', () => { + beforeEach(() => { + reset(); + setVisibilityState(); + }); + + it('should not send data if FCP is null', async () => { + const isDev = true; + await initCoreWebVitals({ browserId, pageViewId, isDev, sampling: 1 }); + + _.coreWebVitalsPayload.fcp = null; // simulate a failing FCP + + setVisibilityState('hidden'); + global.dispatchEvent(new Event('visibilitychange')); + + expect(mockBeacon).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/@guardian/libs/src/coreWebVitals/index.ts b/libs/@guardian/libs/src/coreWebVitals/index.ts new file mode 100644 index 000000000..c4a344df1 --- /dev/null +++ b/libs/@guardian/libs/src/coreWebVitals/index.ts @@ -0,0 +1,207 @@ +import type { ReportHandler } from 'web-vitals'; +import type { TeamName } from '../logger/@types/logger'; +import { log } from '../logger/log'; +import type { CoreWebVitalsPayload } from './@types/CoreWebVitalsPayload'; +import { roundWithDecimals } from './roundWithDecimals'; + +enum Endpoints { + PROD = 'https://performance-events.guardianapis.com/core-web-vitals', + CODE = 'https://performance-events.code.dev-guardianapis.com/core-web-vitals', +} + +const coreWebVitalsPayload: CoreWebVitalsPayload = { + browser_id: null, + page_view_id: null, + fid: null, + cls: null, + lcp: null, + fcp: null, + ttfb: null, +}; + +const teamsForLogging: Set = new Set(); +let endpoint: Endpoints; +let initialised = false; + +const setEndpoint = (isDev: boolean) => { + endpoint = isDev ? Endpoints.CODE : Endpoints.PROD; +}; + +let queued = false; +const sendData = (): void => { + if (queued) return; + + // If we’re missing FCP, the data is unusable in the lake, + // So we’re not sending anything. + if (coreWebVitalsPayload.fcp === null) return; + + queued = navigator.sendBeacon( + endpoint, + JSON.stringify(coreWebVitalsPayload), + ); + + if (teamsForLogging.size > 0) { + teamsForLogging.forEach((team) => { + log( + team, + queued + ? 'Core Web Vitals payload successfully queued for transfer' + : 'Failed to queue Core Web Vitals payload for transfer', + ); + }); + } +}; + +const onReport: ReportHandler = (metric) => { + switch (metric.name) { + case 'FCP': + // Browser support: Chromium, Firefox, Safari Technology Preview + coreWebVitalsPayload.fcp = roundWithDecimals(metric.value); + break; + case 'CLS': + // Browser support: Chromium, + coreWebVitalsPayload.cls = roundWithDecimals(metric.value); + break; + case 'LCP': + // Browser support: Chromium + coreWebVitalsPayload.lcp = roundWithDecimals(metric.value); + break; + case 'FID': + // Browser support: Chromium, Firefox, Safari, Internet Explorer (with the polyfill) + coreWebVitalsPayload.fid = roundWithDecimals(metric.value); + break; + case 'TTFB': + // Browser support: Chromium, Firefox, Safari, Internet Explorer + coreWebVitalsPayload.ttfb = roundWithDecimals(metric.value); + break; + } +}; + +const listener = (e: Event): void => { + switch (e.type) { + case 'visibilitychange': + if (document.visibilityState === 'hidden') sendData(); + return; + case 'pagehide': + sendData(); + return; + } +}; + +const getCoreWebVitals = async (): Promise => { + const webVitals = await import('web-vitals'); + const { getCLS, getFCP, getFID, getLCP, getTTFB } = webVitals; + + getCLS(onReport, false); + getFID(onReport); + getLCP(onReport); + getFCP(onReport); + getTTFB(onReport); + + // Report all available metrics when the page is unloaded or in background. + addEventListener('visibilitychange', listener); + + // Safari does not reliably fire the `visibilitychange` on page unload. + addEventListener('pagehide', listener); +}; + +type InitCoreWebVitalsOptions = { + isDev: boolean; + + browserId?: string | null; + pageViewId?: string | null; + + sampling?: number; + team?: TeamName; +}; + +/** + * Initialise sending Core Web Vitals metrics to a logging endpoint. + * + * @param {InitCoreWebVitalsOptions} init - the initialisation options + * @param init.isDev - used to determine whether to use CODE or PROD endpoints. + * @param init.browserId - identifies the browser. Usually available via `getCookie({ name: 'bwid' })`. Defaults to `null` + * @param init.pageViewId - identifies the page view. Usually available on `guardian.config.ophan.pageViewId`. Defaults to `null` + * + * @param init.sampling - sampling rate for sending data. Defaults to `0.01`. + * + * @param init.team - Optional team to trigger a log event once metrics are queued. + */ +export const initCoreWebVitals = async ({ + browserId = null, + pageViewId = null, + sampling = 1 / 100, // 1% of page view by default + isDev, + team, +}: InitCoreWebVitalsOptions): Promise => { + if (initialised) { + console.warn( + 'initCoreWebVitals already initialised', + 'use the bypassCoreWebVitalsSampling method instead', + ); + return; + } + + initialised = true; + + if (team) teamsForLogging.add(team); + + setEndpoint(isDev); + + coreWebVitalsPayload.browser_id = browserId; + coreWebVitalsPayload.page_view_id = pageViewId; + + if (!browserId || !pageViewId) { + console.warn( + 'browserId or pageViewId missing from Core Web Vitals.', + 'Resulting data cannot be joined to page view tables', + { browserId, pageViewId }, + ); + } + + if (sampling < 0 || sampling > 1) { + console.warn( + 'Core Web Vitals sampling is outside the 0 to 1 range: ', + sampling, + ); + } + if (sampling === 0) console.warn('Core Web Vitals are sampled at 0%'); + if (sampling === 1) console.warn('Core Web Vitals are sampled at 100%'); + + const pageViewInSample = Math.random() < sampling; + const bypassWithHash = + window.location.hash === '#bypassCoreWebVitalsSampling'; + + if (pageViewInSample || bypassWithHash) return getCoreWebVitals(); +}; + +/** + * A method to asynchronously send web vitals after initialization. + * @param team - Optional team to trigger a log event once metrics are queued. + */ +export const bypassCoreWebVitalsSampling = async ( + team?: TeamName, +): Promise => { + if (!initialised) { + console.warn('initCoreWebVitals not yet initialised'); + return; + } + if (team) teamsForLogging.add(team); + return getCoreWebVitals(); +}; + +export const _ = { + coreWebVitalsPayload, + sendData, + reset: (): void => { + initialised = false; + teamsForLogging.clear(); + queued = false; + Object.keys(coreWebVitalsPayload).map((key) => { + coreWebVitalsPayload[key as keyof CoreWebVitalsPayload] = null; + }); + removeEventListener('visibilitychange', listener); + removeEventListener('pagehide', listener); + }, + Endpoints, +}; diff --git a/libs/@guardian/libs/src/coreWebVitals/roundWithDecimals.test.ts b/libs/@guardian/libs/src/coreWebVitals/roundWithDecimals.test.ts new file mode 100644 index 000000000..1df12ec05 --- /dev/null +++ b/libs/@guardian/libs/src/coreWebVitals/roundWithDecimals.test.ts @@ -0,0 +1,24 @@ +import { roundWithDecimals } from './roundWithDecimals'; + +describe('roundWithDecimals', () => { + it.each([ + [1, 3, 3], + [1, 10.0, 10], + [1, 10.3, 10.3], + [1, 2.5, 2.5], + [3, 0.001_234, 0.001], + [4, 0.001_234, 0.001_2], + // eslint-disable-next-line @typescript-eslint/no-loss-of-precision -- it's testing that this is rounded away + [5, 100.102_030_405_060_708_090, 100.102_03], + [6, 12345.000_001_2, 12345.000_001], + [9, 199.001_002_003_456, 199.001_002_003], + ])('With precision %s, %f becomes %f', (precision, before, after) => { + expect(roundWithDecimals(before, precision)).toBe(after); + }); + + it('should handle default precision = 6', () => { + const [before, after] = [12345.000_001_2, 12345.000_001]; + expect(roundWithDecimals(before)).toBe(after); + expect(roundWithDecimals(before, 6)).toBe(after); + }); +}); diff --git a/libs/@guardian/libs/src/coreWebVitals/roundWithDecimals.ts b/libs/@guardian/libs/src/coreWebVitals/roundWithDecimals.ts new file mode 100644 index 000000000..1dad64af6 --- /dev/null +++ b/libs/@guardian/libs/src/coreWebVitals/roundWithDecimals.ts @@ -0,0 +1,4 @@ +export const roundWithDecimals = (value: number, precision = 6): number => { + const power = Math.pow(10, precision); + return Math.round(value * power) / power; +}; diff --git a/libs/@guardian/libs/src/countries/@types/Country.ts b/libs/@guardian/libs/src/countries/@types/Country.ts new file mode 100644 index 000000000..48fd539ed --- /dev/null +++ b/libs/@guardian/libs/src/countries/@types/Country.ts @@ -0,0 +1,6 @@ +import type { CountryCode } from './CountryCode'; + +export type Country = { + countryCode: CountryCode; + name: string; +}; diff --git a/libs/@guardian/libs/src/countries/@types/CountryCode.ts b/libs/@guardian/libs/src/countries/@types/CountryCode.ts new file mode 100644 index 000000000..c5dd9bd89 --- /dev/null +++ b/libs/@guardian/libs/src/countries/@types/CountryCode.ts @@ -0,0 +1,251 @@ +// https://en.wikipedia.org/wiki/ISO_3166-1 +export type CountryCode = + | 'AD' + | 'AD' + | 'AE' + | 'AF' + | 'AG' + | 'AI' + | 'AL' + | 'AM' + | 'AO' + | 'AQ' + | 'AR' + | 'AS' + | 'AT' + | 'AU' + | 'AW' + | 'AX' + | 'AZ' + | 'BA' + | 'BB' + | 'BD' + | 'BE' + | 'BF' + | 'BG' + | 'BH' + | 'BI' + | 'BJ' + | 'BL' + | 'BM' + | 'BN' + | 'BO' + | 'BQ' + | 'BR' + | 'BS' + | 'BT' + | 'BV' + | 'BW' + | 'BY' + | 'BZ' + | 'CA' + | 'CC' + | 'CD' + | 'CF' + | 'CG' + | 'CH' + | 'CI' + | 'CK' + | 'CL' + | 'CM' + | 'CN' + | 'CO' + | 'CR' + | 'CU' + | 'CV' + | 'CW' + | 'CX' + | 'CY' + | 'CZ' + | 'DE' + | 'DJ' + | 'DK' + | 'DM' + | 'DO' + | 'DZ' + | 'EC' + | 'EE' + | 'EG' + | 'EH' + | 'ER' + | 'ES' + | 'ET' + | 'FI' + | 'FJ' + | 'FK' + | 'FM' + | 'FO' + | 'FR' + | 'GA' + | 'GB' + | 'GD' + | 'GE' + | 'GF' + | 'GG' + | 'GH' + | 'GI' + | 'GL' + | 'GM' + | 'GN' + | 'GP' + | 'GQ' + | 'GR' + | 'GS' + | 'GT' + | 'GU' + | 'GW' + | 'GY' + | 'HK' + | 'HM' + | 'HN' + | 'HR' + | 'HT' + | 'HU' + | 'ID' + | 'IE' + | 'IL' + | 'IM' + | 'IN' + | 'IO' + | 'IQ' + | 'IR' + | 'IS' + | 'IT' + | 'JE' + | 'JM' + | 'JO' + | 'JP' + | 'KE' + | 'KG' + | 'KH' + | 'KI' + | 'KM' + | 'KN' + | 'KP' + | 'KR' + | 'KW' + | 'KY' + | 'KZ' + | 'LA' + | 'LB' + | 'LC' + | 'LI' + | 'LK' + | 'LR' + | 'LS' + | 'LT' + | 'LU' + | 'LV' + | 'LY' + | 'MA' + | 'MC' + | 'MD' + | 'ME' + | 'MF' + | 'MG' + | 'MH' + | 'MK' + | 'ML' + | 'MM' + | 'MN' + | 'MO' + | 'MP' + | 'MQ' + | 'MR' + | 'MS' + | 'MT' + | 'MU' + | 'MV' + | 'MW' + | 'MX' + | 'MY' + | 'MZ' + | 'NA' + | 'NC' + | 'NE' + | 'NF' + | 'NG' + | 'NI' + | 'NL' + | 'NO' + | 'NP' + | 'NR' + | 'NU' + | 'NZ' + | 'OM' + | 'PA' + | 'PE' + | 'PF' + | 'PG' + | 'PH' + | 'PK' + | 'PL' + | 'PM' + | 'PN' + | 'PR' + | 'PS' + | 'PT' + | 'PW' + | 'PY' + | 'QA' + | 'RE' + | 'RO' + | 'RS' + | 'RU' + | 'RW' + | 'SA' + | 'SB' + | 'SC' + | 'SD' + | 'SE' + | 'SG' + | 'SH' + | 'SI' + | 'SJ' + | 'SK' + | 'SL' + | 'SM' + | 'SN' + | 'SO' + | 'SR' + | 'SS' + | 'ST' + | 'SV' + | 'SX' + | 'SY' + | 'SZ' + | 'TC' + | 'TD' + | 'TF' + | 'TG' + | 'TH' + | 'TJ' + | 'TK' + | 'TL' + | 'TM' + | 'TN' + | 'TO' + | 'TR' + | 'TT' + | 'TV' + | 'TW' + | 'TZ' + | 'UA' + | 'UG' + | 'UM' + | 'US' + | 'UY' + | 'UZ' + | 'VA' + | 'VC' + | 'VE' + | 'VI' + | 'VN' + | 'VU' + | 'WF' + | 'WS' + | 'YE' + | 'YT' + | 'ZA' + | 'ZM' + | 'ZW'; diff --git a/libs/@guardian/libs/src/countries/@types/CountryKey.ts b/libs/@guardian/libs/src/countries/@types/CountryKey.ts new file mode 100644 index 000000000..ea1a4a85c --- /dev/null +++ b/libs/@guardian/libs/src/countries/@types/CountryKey.ts @@ -0,0 +1,249 @@ +export type CountryKey = + | 'afghanistan' + | 'åland_islands' + | 'albania' + | 'algeria' + | 'american_samoa' + | 'andorra' + | 'angola' + | 'anguilla' + | 'antarctica' + | 'antigua_and_barbuda' + | 'argentina' + | 'armenia' + | 'aruba' + | 'australia' + | 'austria' + | 'azerbaijan' + | 'bahamas' + | 'bahrain' + | 'bangladesh' + | 'barbados' + | 'belarus' + | 'belgium' + | 'belize' + | 'benin' + | 'bermuda' + | 'bhutan' + | 'bolivia' + | 'bosnia_and_herzegovina' + | 'botswana' + | 'bouvet_island' + | 'brazil' + | 'british_indian_ocean_territory' + | 'brunei_darussalam' + | 'bulgaria' + | 'burkina_faso' + | 'burundi' + | 'cabo_verde' + | 'cambodia' + | 'cameroon' + | 'canada' + | 'caribbean_netherlands' + | 'cayman_islands' + | 'central_african_republic' + | 'chad' + | 'chile' + | 'china' + | 'christmas_island' + | 'cocos_keeling_islands' + | 'colombia' + | 'comoros' + | 'congo' + | 'cook_islands' + | 'costa_rica' + | 'croatia' + | 'cuba' + | 'curaçao' + | 'cyprus' + | 'czechia' + | 'democratic_republic_of_the_congo' + | 'denmark' + | 'djibouti' + | 'dominica' + | 'dominican_republic' + | 'ecuador' + | 'egypt' + | 'el_salvador' + | 'equatorial_guinea' + | 'eritrea' + | 'estonia' + | 'eswatini' + | 'ethiopia' + | 'falkland_islands' + | 'faroe_islands' + | 'federated_states_of_micronesia' + | 'fiji' + | 'finland' + | 'france' + | 'french_guiana' + | 'french_polynesia' + | 'french_southern_territories' + | 'gabon' + | 'gambia' + | 'georgia' + | 'germany' + | 'ghana' + | 'gibraltar' + | 'greece' + | 'greenland' + | 'grenada' + | 'guadeloupe' + | 'guam' + | 'guatemala' + | 'guernsey' + | 'guinea' + | 'guinea_bissau' + | 'guyana' + | 'haiti' + | 'heard_island_and_mcdonald_islands' + | 'holy_see' + | 'honduras' + | 'hong_kong' + | 'hungary' + | 'iceland' + | 'india' + | 'indonesia' + | 'iran' + | 'iraq' + | 'ireland' + | 'isle_of_man' + | 'israel' + | 'italy' + | 'ivory_coast' + | 'jamaica' + | 'japan' + | 'jersey' + | 'jordan' + | 'kazakhstan' + | 'kenya' + | 'kiribati' + | 'kuwait' + | 'kyrgyzstan' + | 'laos' + | 'latvia' + | 'lebanon' + | 'lesotho' + | 'liberia' + | 'libya' + | 'liechtenstein' + | 'lithuania' + | 'luxembourg' + | 'macao' + | 'madagascar' + | 'malawi' + | 'malaysia' + | 'maldives' + | 'mali' + | 'malta' + | 'marshall_islands' + | 'martinique' + | 'mauritania' + | 'mauritius' + | 'mayotte' + | 'mexico' + | 'moldova' + | 'monaco' + | 'mongolia' + | 'montenegro' + | 'montserrat' + | 'morocco' + | 'mozambique' + | 'myanmar' + | 'namibia' + | 'nauru' + | 'nepal' + | 'netherlands' + | 'new_caledonia' + | 'new_zealand' + | 'nicaragua' + | 'niger' + | 'nigeria' + | 'niue' + | 'norfolk_island' + | 'north_korea' + | 'north_macedonia' + | 'northern_mariana_islands' + | 'norway' + | 'oman' + | 'pakistan' + | 'palau' + | 'panama' + | 'papua_new_guinea' + | 'paraguay' + | 'peru' + | 'philippines' + | 'pitcairn' + | 'poland' + | 'portugal' + | 'puerto_rico' + | 'qatar' + | 'romania' + | 'russia' + | 'rwanda' + | 'réunion' + | 'saint_barthélemy' + | 'saint_helena_ascension_and_tristan_da_cunha' + | 'saint_kitts_and_nevis' + | 'saint_lucia' + | 'saint_martin' + | 'saint_pierre_and_miquelon' + | 'saint_vincent_and_the_grenadines' + | 'samoa' + | 'san_marino' + | 'sao_tome_and_principe' + | 'saudi_arabia' + | 'senegal' + | 'serbia' + | 'seychelles' + | 'sierra_leone' + | 'singapore' + | 'sint_maarten' + | 'slovakia' + | 'slovenia' + | 'solomon_islands' + | 'somalia' + | 'south_africa' + | 'south_georgia_and_the_south_sandwich_islands' + | 'south_korea' + | 'south_sudan' + | 'spain' + | 'sri_lanka' + | 'state_of_palestine' + | 'sudan' + | 'suriname' + | 'svalbard_and_jan_mayen' + | 'sweden' + | 'switzerland' + | 'syria' + | 'taiwan' + | 'tajikistan' + | 'tanzania' + | 'thailand' + | 'timor_leste' + | 'togo' + | 'tokelau' + | 'tonga' + | 'trinidad_and_tobago' + | 'tunisia' + | 'turkey' + | 'turkmenistan' + | 'turks_and_caicos_islands' + | 'tuvalu' + | 'uganda' + | 'ukraine' + | 'united_arab_emirates' + | 'united_kingdom' + | 'united_states_minor_outlying_islands' + | 'united_states_of_america' + | 'uruguay' + | 'uzbekistan' + | 'vanuatu' + | 'venezuela' + | 'vietnam' + | 'virgin_islands' + | 'wallis_and_futuna' + | 'western_sahara' + | 'yemen' + | 'zambia' + | 'zimbabwe'; diff --git a/libs/@guardian/libs/src/countries/README.md b/libs/@guardian/libs/src/countries/README.md new file mode 100644 index 000000000..42adcf534 --- /dev/null +++ b/libs/@guardian/libs/src/countries/README.md @@ -0,0 +1,64 @@ +# Countries + +Country data and methods to access it. + +## Usage + +```js +import { countries, getCountryByCountryCode } from '@guardian/libs'; + +const countryA = countries.afghanistan; +// { countryCode: 'AF', name: 'Afghanistan' } + +const countryB = getCountryByCountryCode('AX'); +// { countryCode: 'AX', name: 'Åland Islands' } +``` + +## `countries` + +Type: `Record` + +A config object of country metadata. + +```typescript +{ + afghanistan: { + countryCode: 'AF', + name: 'Afghanistan', + }, + åland_islands: { + countryCode: 'AX', + name: 'Åland Islands', + }, + // etc +} +``` + +## `getCountryByCountryCode(countryCode)` + +Returns: `Country` + +Gets a country config object for the country with the passed country code. + +### `countryCode` + +Type: `CountryCode` + +## Types + +### `Country` + +```typescript +type Country = { + countryCode: CountryCode; + name: string; +}; +``` + +### `CountryCode` + +ISO 3166-1 alpha-2 two-letter country code. See [the type definition](../@types/countries.ts) for the full list. + +```typescript +type CountryCode = 'AF' | 'AX' /* etc */; +``` diff --git a/libs/@guardian/libs/src/countries/countries.test.ts b/libs/@guardian/libs/src/countries/countries.test.ts new file mode 100644 index 000000000..2e2611372 --- /dev/null +++ b/libs/@guardian/libs/src/countries/countries.test.ts @@ -0,0 +1,13 @@ +import { countries } from './countries'; + +describe('The countries object', () => { + it('only contains unique country codes', () => { + const codes = Object.values(countries).map((c) => c.countryCode); + expect(codes.length).toBe(new Set(codes).size); + }); + + it('only contains unique country names', () => { + const names = Object.values(countries).map((c) => c.name); + expect(names.length).toBe(new Set(names).size); + }); +}); diff --git a/libs/@guardian/libs/src/countries/countries.ts b/libs/@guardian/libs/src/countries/countries.ts new file mode 100644 index 000000000..858658066 --- /dev/null +++ b/libs/@guardian/libs/src/countries/countries.ts @@ -0,0 +1,997 @@ +import type { Country } from './@types/Country'; +import type { CountryKey } from './@types/CountryKey'; + +export const countries: Record = { + afghanistan: { + countryCode: 'AF', + name: 'Afghanistan', + }, + åland_islands: { + countryCode: 'AX', + name: 'Åland Islands', + }, + albania: { + countryCode: 'AL', + name: 'Albania', + }, + algeria: { + countryCode: 'DZ', + name: 'Algeria', + }, + american_samoa: { + countryCode: 'AS', + name: 'American Samoa', + }, + andorra: { + countryCode: 'AD', + name: 'Andorra', + }, + angola: { + countryCode: 'AO', + name: 'Angola', + }, + anguilla: { + countryCode: 'AI', + name: 'Anguilla', + }, + antarctica: { + countryCode: 'AQ', + name: 'Antarctica', + }, + antigua_and_barbuda: { + countryCode: 'AG', + name: 'Antigua and Barbuda', + }, + argentina: { + countryCode: 'AR', + name: 'Argentina', + }, + armenia: { + countryCode: 'AM', + name: 'Armenia', + }, + aruba: { + countryCode: 'AW', + name: 'Aruba', + }, + australia: { + countryCode: 'AU', + name: 'Australia', + }, + austria: { + countryCode: 'AT', + name: 'Austria', + }, + azerbaijan: { + countryCode: 'AZ', + name: 'Azerbaijan', + }, + bahamas: { + countryCode: 'BS', + name: 'Bahamas', + }, + bahrain: { + countryCode: 'BH', + name: 'Bahrain', + }, + bangladesh: { + countryCode: 'BD', + name: 'Bangladesh', + }, + barbados: { + countryCode: 'BB', + name: 'Barbados', + }, + belarus: { + countryCode: 'BY', + name: 'Belarus', + }, + belgium: { + countryCode: 'BE', + name: 'Belgium', + }, + belize: { + countryCode: 'BZ', + name: 'Belize', + }, + benin: { + countryCode: 'BJ', + name: 'Benin', + }, + bermuda: { + countryCode: 'BM', + name: 'Bermuda', + }, + bhutan: { + countryCode: 'BT', + name: 'Bhutan', + }, + bolivia: { + countryCode: 'BO', + name: 'Bolivia (Plurinational State of)', + }, + bosnia_and_herzegovina: { + countryCode: 'BA', + name: 'Bosnia and Herzegovina', + }, + botswana: { + countryCode: 'BW', + name: 'Botswana', + }, + bouvet_island: { + countryCode: 'BV', + name: 'Bouvet Island', + }, + brazil: { + countryCode: 'BR', + name: 'Brazil', + }, + british_indian_ocean_territory: { + countryCode: 'IO', + name: 'British Indian Ocean Territory', + }, + brunei_darussalam: { + countryCode: 'BN', + name: 'Brunei Darussalam', + }, + bulgaria: { + countryCode: 'BG', + name: 'Bulgaria', + }, + burkina_faso: { + countryCode: 'BF', + name: 'Burkina Faso', + }, + burundi: { + countryCode: 'BI', + name: 'Burundi', + }, + cabo_verde: { + countryCode: 'CV', + name: 'Cabo Verde', + }, + cambodia: { + countryCode: 'KH', + name: 'Cambodia', + }, + cameroon: { + countryCode: 'CM', + name: 'Cameroon', + }, + canada: { + countryCode: 'CA', + name: 'Canada', + }, + caribbean_netherlands: { + countryCode: 'BQ', + name: 'Bonaire, Sint Eustatius and Saba', + }, + cayman_islands: { + countryCode: 'KY', + name: 'Cayman Islands', + }, + central_african_republic: { + countryCode: 'CF', + name: 'Central African Republic', + }, + chad: { + countryCode: 'TD', + name: 'Chad', + }, + chile: { + countryCode: 'CL', + name: 'Chile', + }, + china: { + countryCode: 'CN', + name: 'China', + }, + christmas_island: { + countryCode: 'CX', + name: 'Christmas Island', + }, + cocos_keeling_islands: { + countryCode: 'CC', + name: 'Cocos (Keeling) Islands', + }, + colombia: { + countryCode: 'CO', + name: 'Colombia', + }, + comoros: { + countryCode: 'KM', + name: 'Comoros', + }, + congo: { + countryCode: 'CG', + name: 'Congo', + }, + cook_islands: { + countryCode: 'CK', + name: 'Cook Islands', + }, + costa_rica: { + countryCode: 'CR', + name: 'Costa Rica', + }, + croatia: { + countryCode: 'HR', + name: 'Croatia', + }, + cuba: { + countryCode: 'CU', + name: 'Cuba', + }, + curaçao: { + countryCode: 'CW', + name: 'Curaçao', + }, + cyprus: { + countryCode: 'CY', + name: 'Cyprus', + }, + czechia: { + countryCode: 'CZ', + name: 'Czechia', + }, + democratic_republic_of_the_congo: { + countryCode: 'CD', + name: 'Democratic Republic of the Congo', + }, + denmark: { + countryCode: 'DK', + name: 'Denmark', + }, + djibouti: { + countryCode: 'DJ', + name: 'Djibouti', + }, + dominica: { + countryCode: 'DM', + name: 'Dominica', + }, + dominican_republic: { + countryCode: 'DO', + name: 'Dominican Republic', + }, + ecuador: { + countryCode: 'EC', + name: 'Ecuador', + }, + egypt: { + countryCode: 'EG', + name: 'Egypt', + }, + el_salvador: { + countryCode: 'SV', + name: 'El Salvador', + }, + equatorial_guinea: { + countryCode: 'GQ', + name: 'Equatorial Guinea', + }, + eritrea: { + countryCode: 'ER', + name: 'Eritrea', + }, + estonia: { + countryCode: 'EE', + name: 'Estonia', + }, + eswatini: { + countryCode: 'SZ', + name: 'Eswatini', + }, + ethiopia: { + countryCode: 'ET', + name: 'Ethiopia', + }, + falkland_islands: { + countryCode: 'FK', + name: 'Falkland Islands (Malvinas)', + }, + faroe_islands: { + countryCode: 'FO', + name: 'Faroe Islands', + }, + federated_states_of_micronesia: { + countryCode: 'FM', + name: 'Federated States of Micronesia', + }, + fiji: { + countryCode: 'FJ', + name: 'Fiji', + }, + finland: { + countryCode: 'FI', + name: 'Finland', + }, + france: { + countryCode: 'FR', + name: 'France', + }, + french_guiana: { + countryCode: 'GF', + name: 'French Guiana', + }, + french_polynesia: { + countryCode: 'PF', + name: 'French Polynesia', + }, + french_southern_territories: { + countryCode: 'TF', + name: 'French Southern Territories', + }, + gabon: { + countryCode: 'GA', + name: 'Gabon', + }, + gambia: { + countryCode: 'GM', + name: 'Gambia', + }, + georgia: { + countryCode: 'GE', + name: 'Georgia', + }, + germany: { + countryCode: 'DE', + name: 'Germany', + }, + ghana: { + countryCode: 'GH', + name: 'Ghana', + }, + gibraltar: { + countryCode: 'GI', + name: 'Gibraltar', + }, + greece: { + countryCode: 'GR', + name: 'Greece', + }, + greenland: { + countryCode: 'GL', + name: 'Greenland', + }, + grenada: { + countryCode: 'GD', + name: 'Grenada', + }, + guadeloupe: { + countryCode: 'GP', + name: 'Guadeloupe', + }, + guam: { + countryCode: 'GU', + name: 'Guam', + }, + guatemala: { + countryCode: 'GT', + name: 'Guatemala', + }, + guernsey: { + countryCode: 'GG', + name: 'Guernsey', + }, + guinea: { + countryCode: 'GN', + name: 'Guinea', + }, + guinea_bissau: { + countryCode: 'GW', + name: 'Guinea-Bissau', + }, + guyana: { + countryCode: 'GY', + name: 'Guyana', + }, + haiti: { + countryCode: 'HT', + name: 'Haiti', + }, + heard_island_and_mcdonald_islands: { + countryCode: 'HM', + name: 'Heard Island and McDonald Islands', + }, + holy_see: { + countryCode: 'VA', + name: 'Holy See', + }, + honduras: { + countryCode: 'HN', + name: 'Honduras', + }, + hong_kong: { + countryCode: 'HK', + name: 'Hong Kong', + }, + hungary: { + countryCode: 'HU', + name: 'Hungary', + }, + iceland: { + countryCode: 'IS', + name: 'Iceland', + }, + india: { + countryCode: 'IN', + name: 'India', + }, + indonesia: { + countryCode: 'ID', + name: 'Indonesia', + }, + iran: { + countryCode: 'IR', + name: 'Iran (Islamic Republic of)', + }, + iraq: { + countryCode: 'IQ', + name: 'Iraq', + }, + ireland: { + countryCode: 'IE', + name: 'Ireland', + }, + isle_of_man: { + countryCode: 'IM', + name: 'Isle of Man', + }, + israel: { + countryCode: 'IL', + name: 'Israel', + }, + italy: { + countryCode: 'IT', + name: 'Italy', + }, + ivory_coast: { + countryCode: 'CI', + name: "Côte d'Ivoire", + }, + jamaica: { + countryCode: 'JM', + name: 'Jamaica', + }, + japan: { + countryCode: 'JP', + name: 'Japan', + }, + jersey: { + countryCode: 'JE', + name: 'Jersey', + }, + jordan: { + countryCode: 'JO', + name: 'Jordan', + }, + kazakhstan: { + countryCode: 'KZ', + name: 'Kazakhstan', + }, + kenya: { + countryCode: 'KE', + name: 'Kenya', + }, + kiribati: { + countryCode: 'KI', + name: 'Kiribati', + }, + kuwait: { + countryCode: 'KW', + name: 'Kuwait', + }, + kyrgyzstan: { + countryCode: 'KG', + name: 'Kyrgyzstan', + }, + laos: { + countryCode: 'LA', + name: "Lao People's Democratic Republic", + }, + latvia: { + countryCode: 'LV', + name: 'Latvia', + }, + lebanon: { + countryCode: 'LB', + name: 'Lebanon', + }, + lesotho: { + countryCode: 'LS', + name: 'Lesotho', + }, + liberia: { + countryCode: 'LR', + name: 'Liberia', + }, + libya: { + countryCode: 'LY', + name: 'Libya', + }, + liechtenstein: { + countryCode: 'LI', + name: 'Liechtenstein', + }, + lithuania: { + countryCode: 'LT', + name: 'Lithuania', + }, + luxembourg: { + countryCode: 'LU', + name: 'Luxembourg', + }, + macao: { + countryCode: 'MO', + name: 'Macao', + }, + madagascar: { + countryCode: 'MG', + name: 'Madagascar', + }, + malawi: { + countryCode: 'MW', + name: 'Malawi', + }, + malaysia: { + countryCode: 'MY', + name: 'Malaysia', + }, + maldives: { + countryCode: 'MV', + name: 'Maldives', + }, + mali: { + countryCode: 'ML', + name: 'Mali', + }, + malta: { + countryCode: 'MT', + name: 'Malta', + }, + marshall_islands: { + countryCode: 'MH', + name: 'Marshall Islands', + }, + martinique: { + countryCode: 'MQ', + name: 'Martinique', + }, + mauritania: { + countryCode: 'MR', + name: 'Mauritania', + }, + mauritius: { + countryCode: 'MU', + name: 'Mauritius', + }, + mayotte: { + countryCode: 'YT', + name: 'Mayotte', + }, + mexico: { + countryCode: 'MX', + name: 'Mexico', + }, + moldova: { + countryCode: 'MD', + name: 'Republic of Moldova', + }, + monaco: { + countryCode: 'MC', + name: 'Monaco', + }, + mongolia: { + countryCode: 'MN', + name: 'Mongolia', + }, + montenegro: { + countryCode: 'ME', + name: 'Montenegro', + }, + montserrat: { + countryCode: 'MS', + name: 'Montserrat', + }, + morocco: { + countryCode: 'MA', + name: 'Morocco', + }, + mozambique: { + countryCode: 'MZ', + name: 'Mozambique', + }, + myanmar: { + countryCode: 'MM', + name: 'Myanmar', + }, + namibia: { + countryCode: 'NA', + name: 'Namibia', + }, + nauru: { + countryCode: 'NR', + name: 'Nauru', + }, + nepal: { + countryCode: 'NP', + name: 'Nepal', + }, + netherlands: { + countryCode: 'NL', + name: 'Netherlands', + }, + new_caledonia: { + countryCode: 'NC', + name: 'New Caledonia', + }, + new_zealand: { + countryCode: 'NZ', + name: 'New Zealand', + }, + nicaragua: { + countryCode: 'NI', + name: 'Nicaragua', + }, + niger: { + countryCode: 'NE', + name: 'Niger', + }, + nigeria: { + countryCode: 'NG', + name: 'Nigeria', + }, + niue: { + countryCode: 'NU', + name: 'Niue', + }, + norfolk_island: { + countryCode: 'NF', + name: 'Norfolk Island', + }, + north_korea: { + countryCode: 'KP', + name: "Democratic People's Republic of Korea", + }, + north_macedonia: { + countryCode: 'MK', + name: 'North Macedonia', + }, + northern_mariana_islands: { + countryCode: 'MP', + name: 'Northern Mariana Islands', + }, + norway: { + countryCode: 'NO', + name: 'Norway', + }, + oman: { + countryCode: 'OM', + name: 'Oman', + }, + pakistan: { + countryCode: 'PK', + name: 'Pakistan', + }, + palau: { + countryCode: 'PW', + name: 'Palau', + }, + panama: { + countryCode: 'PA', + name: 'Panama', + }, + papua_new_guinea: { + countryCode: 'PG', + name: 'Papua New Guinea', + }, + paraguay: { + countryCode: 'PY', + name: 'Paraguay', + }, + peru: { + countryCode: 'PE', + name: 'Peru', + }, + philippines: { + countryCode: 'PH', + name: 'Philippines', + }, + pitcairn: { + countryCode: 'PN', + name: 'Pitcairn', + }, + poland: { + countryCode: 'PL', + name: 'Poland', + }, + portugal: { + countryCode: 'PT', + name: 'Portugal', + }, + puerto_rico: { + countryCode: 'PR', + name: 'Puerto Rico', + }, + qatar: { + countryCode: 'QA', + name: 'Qatar', + }, + romania: { + countryCode: 'RO', + name: 'Romania', + }, + russia: { + countryCode: 'RU', + name: 'Russian Federation', + }, + rwanda: { + countryCode: 'RW', + name: 'Rwanda', + }, + réunion: { + countryCode: 'RE', + name: 'Réunion', + }, + saint_barthélemy: { + countryCode: 'BL', + name: 'Saint Barthélemy', + }, + saint_helena_ascension_and_tristan_da_cunha: { + countryCode: 'SH', + name: 'Saint Helena, Ascension and Tristan da Cunha', + }, + saint_kitts_and_nevis: { + countryCode: 'KN', + name: 'Saint Kitts and Nevis', + }, + saint_lucia: { + countryCode: 'LC', + name: 'Saint Lucia', + }, + saint_martin: { + countryCode: 'MF', + name: 'Saint Martin (French part)', + }, + saint_pierre_and_miquelon: { + countryCode: 'PM', + name: 'Saint Pierre and Miquelon', + }, + saint_vincent_and_the_grenadines: { + countryCode: 'VC', + name: 'Saint Vincent and the Grenadines', + }, + samoa: { + countryCode: 'WS', + name: 'Samoa', + }, + san_marino: { + countryCode: 'SM', + name: 'San Marino', + }, + sao_tome_and_principe: { + countryCode: 'ST', + name: 'Sao Tome and Principe', + }, + saudi_arabia: { + countryCode: 'SA', + name: 'Saudi Arabia', + }, + senegal: { + countryCode: 'SN', + name: 'Senegal', + }, + serbia: { + countryCode: 'RS', + name: 'Serbia', + }, + seychelles: { + countryCode: 'SC', + name: 'Seychelles', + }, + sierra_leone: { + countryCode: 'SL', + name: 'Sierra Leone', + }, + singapore: { + countryCode: 'SG', + name: 'Singapore', + }, + sint_maarten: { + countryCode: 'SX', + name: 'Sint Maarten (Dutch part)', + }, + slovakia: { + countryCode: 'SK', + name: 'Slovakia', + }, + slovenia: { + countryCode: 'SI', + name: 'Slovenia', + }, + solomon_islands: { + countryCode: 'SB', + name: 'Solomon Islands', + }, + somalia: { + countryCode: 'SO', + name: 'Somalia', + }, + south_africa: { + countryCode: 'ZA', + name: 'South Africa', + }, + south_georgia_and_the_south_sandwich_islands: { + countryCode: 'GS', + name: 'South Georgia and the South Sandwich Islands', + }, + south_korea: { + countryCode: 'KR', + name: 'Republic of Korea', + }, + south_sudan: { + countryCode: 'SS', + name: 'South Sudan', + }, + spain: { + countryCode: 'ES', + name: 'Spain', + }, + sri_lanka: { + countryCode: 'LK', + name: 'Sri Lanka', + }, + state_of_palestine: { + countryCode: 'PS', + name: 'State of Palestine', + }, + sudan: { + countryCode: 'SD', + name: 'Sudan', + }, + suriname: { + countryCode: 'SR', + name: 'Suriname', + }, + svalbard_and_jan_mayen: { + countryCode: 'SJ', + name: 'Svalbard and Jan Mayen', + }, + sweden: { + countryCode: 'SE', + name: 'Sweden', + }, + switzerland: { + countryCode: 'CH', + name: 'Switzerland', + }, + syria: { + countryCode: 'SY', + name: 'Syrian Arab Republic', + }, + taiwan: { + countryCode: 'TW', + name: 'Taiwan, Province of China', + }, + tajikistan: { + countryCode: 'TJ', + name: 'Tajikistan', + }, + tanzania: { + countryCode: 'TZ', + name: 'United Republic of Tanzania', + }, + thailand: { + countryCode: 'TH', + name: 'Thailand', + }, + timor_leste: { + countryCode: 'TL', + name: 'Timor-Leste', + }, + togo: { + countryCode: 'TG', + name: 'Togo', + }, + tokelau: { + countryCode: 'TK', + name: 'Tokelau', + }, + tonga: { + countryCode: 'TO', + name: 'Tonga', + }, + trinidad_and_tobago: { + countryCode: 'TT', + name: 'Trinidad and Tobago', + }, + tunisia: { + countryCode: 'TN', + name: 'Tunisia', + }, + turkey: { + countryCode: 'TR', + name: 'Turkey', + }, + turkmenistan: { + countryCode: 'TM', + name: 'Turkmenistan', + }, + turks_and_caicos_islands: { + countryCode: 'TC', + name: 'Turks and Caicos Islands', + }, + tuvalu: { + countryCode: 'TV', + name: 'Tuvalu', + }, + uganda: { + countryCode: 'UG', + name: 'Uganda', + }, + ukraine: { + countryCode: 'UA', + name: 'Ukraine', + }, + united_arab_emirates: { + countryCode: 'AE', + name: 'United Arab Emirates', + }, + united_kingdom: { + countryCode: 'GB', + name: 'United Kingdom of Great Britain and Northern Ireland', + }, + united_states_minor_outlying_islands: { + countryCode: 'UM', + name: 'United States Minor Outlying Islands', + }, + united_states_of_america: { + countryCode: 'US', + name: 'United States of America', + }, + uruguay: { + countryCode: 'UY', + name: 'Uruguay', + }, + uzbekistan: { + countryCode: 'UZ', + name: 'Uzbekistan', + }, + vanuatu: { + countryCode: 'VU', + name: 'Vanuatu', + }, + venezuela: { + countryCode: 'VE', + name: 'Bolivarian Republic of Venezuela', + }, + vietnam: { + countryCode: 'VN', + name: 'Viet Nam', + }, + virgin_islands: { + countryCode: 'VI', + name: 'Virgin Islands (U.S.)', + }, + wallis_and_futuna: { + countryCode: 'WF', + name: 'Wallis and Futuna', + }, + western_sahara: { + countryCode: 'EH', + name: 'Western Sahara', + }, + yemen: { + countryCode: 'YE', + name: 'Yemen', + }, + zambia: { + countryCode: 'ZM', + name: 'Zambia', + }, + zimbabwe: { + countryCode: 'ZW', + name: 'Zimbabwe', + }, +} as const; diff --git a/libs/@guardian/libs/src/countries/getCountryByCountryCode.test.ts b/libs/@guardian/libs/src/countries/getCountryByCountryCode.test.ts new file mode 100644 index 000000000..430066975 --- /dev/null +++ b/libs/@guardian/libs/src/countries/getCountryByCountryCode.test.ts @@ -0,0 +1,10 @@ +import { getCountryByCountryCode } from './getCountryByCountryCode'; + +describe('The getCountryByCountryCode', () => { + it('returns a country object', () => { + expect(getCountryByCountryCode('GB')).toEqual({ + name: 'United Kingdom of Great Britain and Northern Ireland', + countryCode: 'GB', + }); + }); +}); diff --git a/libs/@guardian/libs/src/countries/getCountryByCountryCode.ts b/libs/@guardian/libs/src/countries/getCountryByCountryCode.ts new file mode 100644 index 000000000..63de2e4cf --- /dev/null +++ b/libs/@guardian/libs/src/countries/getCountryByCountryCode.ts @@ -0,0 +1,12 @@ +import type { Country } from './@types/Country'; +import type { CountryCode } from './@types/CountryCode'; +import { countries } from './countries'; + +export const getCountryByCountryCodeCache: { + [prop in CountryCode]?: Country; +} = {}; + +export const getCountryByCountryCode = (countryCode: CountryCode): Country => + (getCountryByCountryCodeCache[countryCode] ||= Object.values( + countries, + ).find((country) => country.countryCode === countryCode)) as Country; diff --git a/libs/@guardian/libs/src/datetime/README.md b/libs/@guardian/libs/src/datetime/README.md new file mode 100644 index 000000000..e07718af4 --- /dev/null +++ b/libs/@guardian/libs/src/datetime/README.md @@ -0,0 +1,37 @@ + +# `timeAgo` + +Takes an absolute date in [epoch format](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#description) and returns a string representing relative time ago. + +## Usage +`timeAgo(epoch, opts)` + +Returns: `string | false` + +Converts an absolute epoch to a relative time ago string + +`epoch` + +Type: `number` + +The date when an event happened in epoch format + +`opts` + +Type: +```typescript +type Opts = { + verbose?: boolean, // Return a longer, more descriptive string when true + daysUntilAbsolute?: number = 7, // The cutoff for when dates are returned in absolute format +} +``` + +Options to control the response + +## Examples +```ts +timeAgo(twoSecondsAgoAsEpoch) // 'now' +timeAgo(fiveMinutesAgoAsEpoch) // '5m ago' +timeAgo(twoDaysAgoAsEpoch) // '2d ago' +timeAgo(sixDaysAgoAsEpoch, { verbose: true }) // '6 days ago' +timeAgo(sixDaysAgoAsEpoch, { daysUntilAbsolute: 4 }) // '12 Mar 2021' diff --git a/libs/@guardian/libs/src/datetime/timeAgo.test.ts b/libs/@guardian/libs/src/datetime/timeAgo.test.ts new file mode 100644 index 000000000..808a8ec3a --- /dev/null +++ b/libs/@guardian/libs/src/datetime/timeAgo.test.ts @@ -0,0 +1,228 @@ +import MockDate from 'mockdate'; +import { timeAgo } from './timeAgo'; + +describe('timeAgo', () => { + beforeAll(() => { + MockDate.set('Sun Nov 17 2019 12:00:00 GMT+0000 (Greenwich Mean Time)'); + }); + + afterAll(() => { + MockDate.reset(); + }); + + it('returns a short date string for older dates', () => { + const older = new Date(Date.UTC(2019, 1, 1)).getTime(); + expect(timeAgo(older)).toBe('1 Feb 2019'); + }); + + it('returns "now" when within 15 seconds', () => { + const fourteenSecondsAgo = new Date( + Date.UTC(2019, 10, 17, 11, 59, 46), + ).getTime(); + expect(timeAgo(fourteenSecondsAgo)).toBe('now'); + }); + + it('returns seconds for very recent dates', () => { + const secondsAgo = new Date( + Date.UTC(2019, 10, 17, 11, 59, 30), + ).getTime(); + expect(timeAgo(secondsAgo)).toBe('30s ago'); + }); + + it('returns minutes for slightly recent dates', () => { + const fiveMinutesAgo = new Date( + Date.UTC(2019, 10, 17, 11, 55, 0), + ).getTime(); + expect(timeAgo(fiveMinutesAgo)).toBe('5m ago'); + }); + + it('returns hours for dates within the last 24 hours', () => { + const twoHoursAgo = new Date( + Date.UTC(2019, 10, 17, 10, 0, 0), + ).getTime(); + expect(timeAgo(twoHoursAgo)).toBe('2h ago'); + }); + + it('returns days for dates within one week', () => { + const twoDaysAgo = new Date(Date.UTC(2019, 10, 15, 13, 0, 0)).getTime(); + expect(timeAgo(twoDaysAgo)).toBe('2d ago'); + }); + + it('returns an absolute date for dates over a week old', () => { + const eightDaysAgo = new Date( + Date.UTC(2019, 10, 9, 13, 0, 0), + ).getTime(); + + expect(timeAgo(eightDaysAgo)).toBe('9 Nov 2019'); + }); + + it('returns a longer absolute date when verbose is true', () => { + const eightDaysAgo = new Date( + Date.UTC(2019, 10, 9, 13, 0, 0), + ).getTime(); + + expect( + timeAgo(eightDaysAgo, { + verbose: true, + }), + ).toBe('9 November 2019'); + }); + + it('returns "yesterday" only when verbose option given', () => { + const yesterday = new Date(Date.UTC(2019, 10, 16, 3, 0, 0)).getTime(); + + expect(timeAgo(yesterday)).toBe('1d ago'); + expect(timeAgo(yesterday, { verbose: true })).toBe('Yesterday 3.00'); + }); + + it('does not pluralise the unit when the delta is one', () => { + const oneMinuteAgo = new Date( + Date.UTC(2019, 10, 17, 11, 59, 0), + ).getTime(); + const oneHourAgo = new Date(Date.UTC(2019, 10, 17, 11, 0, 0)).getTime(); + const oneDayAgo = new Date(Date.UTC(2019, 10, 16, 12, 0, 0)).getTime(); + + expect(timeAgo(oneHourAgo)).toBe('1h ago'); + expect( + timeAgo(oneHourAgo, { + verbose: true, + }), + ).toBe('1 hour ago'); + + expect(timeAgo(oneMinuteAgo)).toBe('1m ago'); + expect( + timeAgo(oneMinuteAgo, { + verbose: true, + }), + ).toBe('1 minute ago'); + + expect(timeAgo(oneDayAgo)).toBe('1d ago'); + expect( + timeAgo(oneDayAgo, { + verbose: true, + }), + ).toBe('Yesterday 12.00'); + }); + + it('returns verbose format for seconds when this option is given', () => { + const twentySecondsAgo = new Date( + Date.UTC(2019, 10, 17, 11, 59, 40), + ).getTime(); + expect( + timeAgo(twentySecondsAgo, { + verbose: true, + }), + ).toBe('20 seconds ago'); + }); + + it('returns verbose format for minutes when this option is given', () => { + const fiveMinutesAgo = new Date( + Date.UTC(2019, 10, 17, 11, 55, 0), + ).getTime(); + expect( + timeAgo(fiveMinutesAgo, { + verbose: true, + }), + ).toBe('5 minutes ago'); + }); + + it('returns verbose format for hours when this option is given', () => { + const twoHoursAgo = new Date( + Date.UTC(2019, 10, 17, 10, 0, 0), + ).getTime(); + expect( + timeAgo(twoHoursAgo, { + verbose: true, + }), + ).toBe('2 hours ago'); + }); + + it('returns verbose format for days when this option is given', () => { + const twoDaysAgo = new Date(Date.UTC(2019, 10, 15, 10, 0, 0)).getTime(); + expect( + timeAgo(twoDaysAgo, { + verbose: true, + }), + ).toBe('2 days ago'); + }); + + it('still returns a relative string for dates yesterday if within 24hs', () => { + const twentyHoursAgo = new Date( + Date.UTC(2019, 10, 16, 16, 0, 0), + ).getTime(); + expect(timeAgo(twentyHoursAgo)).toBe('20h ago'); + }); + + it('still returns an verbose relative string for dates yesterday if within 24hs', () => { + const twentyHoursAgo = new Date( + Date.UTC(2019, 10, 16, 16, 0, 0), + ).getTime(); + expect( + timeAgo(twentyHoursAgo, { + verbose: true, + }), + ).toBe('20 hours ago'); + }); + + it('still returns "yesterday" when epoch is the previous day but only if over 24hrs', () => { + const thirtyHoursAgo = new Date( + Date.UTC(2019, 10, 16, 6, 0, 0), + ).getTime(); + expect( + timeAgo(thirtyHoursAgo, { + verbose: true, + }), + ).toBe('Yesterday 6.00'); + }); + + it('returns absolute format dates for dates over one week ago, regardless of options', () => { + const oneMonthAgo = new Date(Date.UTC(2019, 9, 17, 13, 0, 0)).getTime(); + expect(timeAgo(oneMonthAgo)).toBe('17 Oct 2019'); + expect(timeAgo(oneMonthAgo, { verbose: false })).toBe('17 Oct 2019'); + expect(timeAgo(oneMonthAgo, { verbose: true })).toBe('17 October 2019'); + }); + + it('returns days when within 5 days', () => { + const twoDaysAgo = new Date(Date.UTC(2019, 10, 15, 13, 0, 0)).getTime(); + const fourDaysAgo = new Date( + Date.UTC(2019, 10, 13, 13, 0, 0), + ).getTime(); + const fiveDaysAgo = new Date( + Date.UTC(2019, 10, 12, 13, 0, 0), + ).getTime(); + expect(timeAgo(twoDaysAgo)).toBe('2d ago'); + expect(timeAgo(fourDaysAgo)).toBe('4d ago'); + expect(timeAgo(fiveDaysAgo)).toBe('5d ago'); + }); + + it('returns absolute dates after 7 days', () => { + const sevenDaysAgo = new Date( + Date.UTC(2019, 10, 10, 13, 0, 0), + ).getTime(); + const eightDaysAgo = new Date( + Date.UTC(2019, 10, 9, 13, 0, 0), + ).getTime(); + expect(timeAgo(sevenDaysAgo)).toBe('7d ago'); + expect(timeAgo(eightDaysAgo)).toBe('9 Nov 2019'); + }); + + it('correctly changes format based on the daysUntilAbsolute option', () => { + const tenDaysAgo = new Date(Date.UTC(2019, 10, 7, 13, 0, 0)).getTime(); + expect(timeAgo(tenDaysAgo)).toBe('7 Nov 2019'); + expect(timeAgo(tenDaysAgo, { daysUntilAbsolute: 14 })).toBe('10d ago'); + }); + + it('defaults to a simple date format for dates over 1 week old', () => { + const eightDaysAgo = new Date( + Date.UTC(2019, 10, 9, 13, 0, 0), + ).getTime(); + const aWhileBack = new Date(Date.UTC(2017, 3, 2, 17, 0, 0)).getTime(); + expect(timeAgo(eightDaysAgo)).toBe('9 Nov 2019'); + expect(timeAgo(aWhileBack)).toBe('2 Apr 2017'); + }); + + it('returns false on future dates', () => { + const tomorrow = new Date(Date.UTC(2019, 10, 18, 10, 0, 0)).getTime(); + expect(timeAgo(tomorrow)).toBe(false); + }); +}); diff --git a/libs/@guardian/libs/src/datetime/timeAgo.ts b/libs/@guardian/libs/src/datetime/timeAgo.ts new file mode 100644 index 000000000..ae7b3b582 --- /dev/null +++ b/libs/@guardian/libs/src/datetime/timeAgo.ts @@ -0,0 +1,114 @@ +type Unit = 's' | 'm' | 'h' | 'd'; + +const pad = (n: number): number | string => n.toString().padStart(2, '0'); + +const isWithin24Hours = (date: Date): boolean => { + const today = new Date(); + return date.getTime() > today.getTime() - 24 * 60 * 60 * 1000; +}; + +const isYesterday = (relative: Date): boolean => { + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + return relative.toDateString() === yesterday.toDateString(); +}; + +const getSuffix = (type: Unit, value: number, verbose?: boolean): string => { + const shouldPluralise = value !== 1; + switch (type) { + case 's': { + // Always pluralised, as less than 15 seconds returns “now” + if (verbose) return ' seconds ago'; + return 's ago'; + } + case 'm': { + if (verbose && shouldPluralise) return ' minutes ago'; + if (verbose) return ' minute ago'; + return 'm ago'; + } + case 'h': { + if (verbose && shouldPluralise) return ' hours ago'; + if (verbose) return ' hour ago'; + return 'h ago'; + } + case 'd': { + // Always pluralised, as less than 2 days returns “Yesterday HH.MM” + if (verbose) return ' days ago'; + return 'd ago'; + } + } +}; + +const withTime = (date: Date): string => + ` ${date.getHours()}.${pad(date.getMinutes())}`; + +/** + * Takes an absolute date in [epoch format] and returns a string representing + * relative time ago. + * + * Time is formatted according to [the Guardian and Observer Style Guide (T)][T] + * + * @param {number} epoch The date when an event happened in epoch format + * @param {Object} [options] Options to control the formatting + * @returns {string | false} A formatted relative time string, or `false` if the epoch is in the future + * + * [epoch format]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#description + * [T]: https://www.theguardian.com/guardian-observer-style-guide-t + */ +export const timeAgo = ( + epoch: number, + options?: { + verbose?: boolean; + daysUntilAbsolute?: number; + }, +): false | string => { + const then = new Date(epoch); + const now = new Date(); + + const verbose = options?.verbose; + const daysUntilAbsolute = options?.daysUntilAbsolute ?? 7; + + const secondsAgo = Math.floor((now.getTime() - then.getTime()) / 1000); + const veryClose = secondsAgo < 15; + const within55Seconds = secondsAgo < 55; + const withinTheHour = secondsAgo < 55 * 60; + const within24hrs = isWithin24Hours(then); + const wasYesterday = isYesterday(then); + const withinAbsoluteCutoff = secondsAgo < daysUntilAbsolute * 24 * 60 * 60; + + if (secondsAgo < 0) { + // Dates in the future are not supported + return false; + } else if (veryClose) { + // Now + return 'now'; + } else if (within55Seconds) { + // Seconds + return `${secondsAgo}${getSuffix('s', secondsAgo, verbose)}`; + } else if (withinTheHour) { + // Minutes + const minutes = Math.round(secondsAgo / 60); + return `${minutes}${getSuffix('m', minutes, verbose)}`; + } else if (within24hrs) { + // Hours + const hours = Math.round(secondsAgo / 3600); + return `${hours}${getSuffix('h', hours, verbose)}`; + } else if (wasYesterday && verbose) { + // Yesterday + return `Yesterday${withTime(then)}`; + } else if (withinAbsoluteCutoff) { + // Days + const days = Math.round(secondsAgo / 3600 / 24); + return `${days}${getSuffix('d', days, verbose)}`; + } else { + // Simple date - "9 Nov 2019" + return [ + then.getDate(), + verbose + ? then.toLocaleString('en-GB', { month: 'long' }) + : then.toLocaleString('en-GB', { month: 'short' }), + then.getFullYear(), + ].join(' '); + } +}; diff --git a/libs/@guardian/libs/src/format/ArticleDesign.ts b/libs/@guardian/libs/src/format/ArticleDesign.ts new file mode 100644 index 000000000..a744e302b --- /dev/null +++ b/libs/@guardian/libs/src/format/ArticleDesign.ts @@ -0,0 +1,26 @@ +export enum ArticleDesign { + Standard, + Gallery, + Audio, + Video, + Review, + Analysis, + Explainer, + Comment, + Letter, + Feature, + LiveBlog, + DeadBlog, + Recipe, + MatchReport, + Interview, + Editorial, + Quiz, + Interactive, + PhotoEssay, + PrintShop, + Obituary, + Correction, + FullPageInteractive, + NewsletterSignup, +} diff --git a/libs/@guardian/libs/src/format/ArticleDisplay.ts b/libs/@guardian/libs/src/format/ArticleDisplay.ts new file mode 100644 index 000000000..517c7b5d5 --- /dev/null +++ b/libs/@guardian/libs/src/format/ArticleDisplay.ts @@ -0,0 +1,6 @@ +export enum ArticleDisplay { + Standard, + Immersive, + Showcase, + NumberedList, +} diff --git a/libs/@guardian/libs/src/format/ArticleFormat.ts b/libs/@guardian/libs/src/format/ArticleFormat.ts new file mode 100644 index 000000000..6193e2d84 --- /dev/null +++ b/libs/@guardian/libs/src/format/ArticleFormat.ts @@ -0,0 +1,9 @@ +import type { ArticleDesign } from './ArticleDesign'; +import type { ArticleDisplay } from './ArticleDisplay'; +import type { ArticleTheme } from './ArticleTheme'; + +export interface ArticleFormat { + theme: ArticleTheme; + design: ArticleDesign; + display: ArticleDisplay; +} diff --git a/libs/@guardian/libs/src/format/ArticlePillar.ts b/libs/@guardian/libs/src/format/ArticlePillar.ts new file mode 100644 index 000000000..dcce2b386 --- /dev/null +++ b/libs/@guardian/libs/src/format/ArticlePillar.ts @@ -0,0 +1,7 @@ +export enum ArticlePillar { + News = 0, + Opinion = 1, + Sport = 2, + Culture = 3, + Lifestyle = 4, +} diff --git a/libs/@guardian/libs/src/format/ArticleSpecial.ts b/libs/@guardian/libs/src/format/ArticleSpecial.ts new file mode 100644 index 000000000..689de0efb --- /dev/null +++ b/libs/@guardian/libs/src/format/ArticleSpecial.ts @@ -0,0 +1,4 @@ +export enum ArticleSpecial { + SpecialReport = 5, + Labs = 6, +} diff --git a/libs/@guardian/libs/src/format/ArticleTheme.ts b/libs/@guardian/libs/src/format/ArticleTheme.ts new file mode 100644 index 000000000..4245f431d --- /dev/null +++ b/libs/@guardian/libs/src/format/ArticleTheme.ts @@ -0,0 +1,4 @@ +import type { ArticlePillar } from './ArticlePillar'; +import type { ArticleSpecial } from './ArticleSpecial'; + +export type ArticleTheme = ArticlePillar | ArticleSpecial; diff --git a/libs/@guardian/libs/src/format/README.md b/libs/@guardian/libs/src/format/README.md new file mode 100644 index 000000000..c8639adc0 --- /dev/null +++ b/libs/@guardian/libs/src/format/README.md @@ -0,0 +1,15 @@ +# Format + +Types and enums related to editorial formats. + +## Usage + +```ts +import type { ArticleTheme, ArticleFormat } from '@guardian/libs'; +import { + ArticlePillar, + ArticleSpecial, + ArticleDesign, + ArticleDisplay, +} from '@guardian/libs'; +``` diff --git a/libs/@guardian/libs/src/format/format.test.ts b/libs/@guardian/libs/src/format/format.test.ts new file mode 100644 index 000000000..cca24210c --- /dev/null +++ b/libs/@guardian/libs/src/format/format.test.ts @@ -0,0 +1,20 @@ +import { ArticleDesign } from './ArticleDesign'; +import { ArticleDisplay } from './ArticleDisplay'; +import { ArticlePillar } from './ArticlePillar'; +import { ArticleSpecial } from './ArticleSpecial'; + +it('Design enum contains Article', () => { + expect(ArticleDesign.Standard).toBeDefined(); +}); + +it('Display enum contains Standard', () => { + expect(ArticleDisplay.Standard).toBeDefined(); +}); + +it('Pillar enum contains News', () => { + expect(ArticlePillar.News).toBe(0); +}); + +it('Special enum contains SpecialReport', () => { + expect(ArticleSpecial.SpecialReport).toBe(5); +}); diff --git a/libs/@guardian/libs/src/index.test.ts b/libs/@guardian/libs/src/index.test.ts new file mode 100644 index 000000000..0f83f8af4 --- /dev/null +++ b/libs/@guardian/libs/src/index.test.ts @@ -0,0 +1,54 @@ +import * as packageExports from './index'; + +describe('The package', () => { + it('exports everything it did before', () => { + expect(Object.keys(packageExports).sort()).toEqual([ + 'ArticleDesign', + 'ArticleDisplay', + 'ArticleElementRole', + 'ArticlePillar', + 'ArticleSpecial', + 'bypassCoreWebVitalsSampling', + 'countries', + 'debug', + 'getCookie', + 'getCountryByCountryCode', + 'getLocale', + 'getSwitches', + 'initCoreWebVitals', + 'isBoolean', + 'isObject', + 'isString', + 'isUndefined', + 'joinUrl', + 'loadScript', + 'log', + 'removeCookie', + 'setCookie', + 'setSessionCookie', + 'storage', + 'timeAgo', + ]); + }); +}); + +// test that type exports have not been removed. +// won't catch new types but I don't know how we can? +export type { + ArticleFormat, + ArticleTheme, + Country, + CountryCode, + OphanABEvent, + OphanABPayload, + OphanABTestMeta, + OphanAction, + OphanComponent, + OphanComponentEvent, + OphanComponentType, + OphanProduct, + Switches, +} from './index'; + +// @ts-expect-error: make sure the above list are real exports +export type { ThisTypeDoesNotExist } from './index'; diff --git a/libs/@guardian/libs/src/index.ts b/libs/@guardian/libs/src/index.ts index 2f2ae2380..d25eafcee 100644 --- a/libs/@guardian/libs/src/index.ts +++ b/libs/@guardian/libs/src/index.ts @@ -1 +1,58 @@ -export * from 'original-libs'; +/* istanbul ignore file */ + +export { ArticleElementRole } from './ArticleElementRole/ArticleElementRole'; + +export { getCookie } from './cookies/getCookie'; +export { removeCookie } from './cookies/removeCookie'; +export { setCookie } from './cookies/setCookie'; +export { setSessionCookie } from './cookies/setSessionCookie'; + +export { + bypassCoreWebVitalsSampling, + initCoreWebVitals, +} from './coreWebVitals'; + +export type { Country } from './countries/@types/Country'; +export type { CountryCode } from './countries/@types/CountryCode'; +export { countries } from './countries/countries'; +export { getCountryByCountryCode } from './countries/getCountryByCountryCode'; + +export { timeAgo } from './datetime/timeAgo'; + +export { ArticleDesign } from './format/ArticleDesign'; +export { ArticleDisplay } from './format/ArticleDisplay'; +export type { ArticleFormat } from './format/ArticleFormat'; +export { ArticlePillar } from './format/ArticlePillar'; +export { ArticleSpecial } from './format/ArticleSpecial'; +export type { ArticleTheme } from './format/ArticleTheme'; + +export { isBoolean } from './isBoolean/isBoolean'; +export { isObject } from './isObject/isObject'; +export { isString } from './isString/isString'; +export { isUndefined } from './isUndefined/isUndefined'; + +export { joinUrl } from './joinUrl/joinUrl'; + +export { loadScript } from './loadScript/loadScript'; + +export { getLocale } from './locale/getLocale'; + +export { debug } from './logger/debug'; +export { log } from './logger/log'; +export type { TeamName } from './logger/@types/logger'; + +export type { + OphanABEvent, + OphanABPayload, + OphanABTestMeta, + OphanAction, + OphanComponent, + OphanComponentEvent, + OphanComponentType, + OphanProduct, +} from './ophan/@types'; + +export { storage } from './storage/storage'; + +export type { Switches } from './switches/@types/Switches'; +export { getSwitches } from './switches/getSwitches'; diff --git a/libs/@guardian/libs/src/isBoolean/README.md b/libs/@guardian/libs/src/isBoolean/README.md new file mode 100644 index 000000000..d79ba4163 --- /dev/null +++ b/libs/@guardian/libs/src/isBoolean/README.md @@ -0,0 +1,14 @@ +# `isBoolean(value)` + +Returns: `boolean` + +Checks whether `value` is a boolean. + +## Example + +```js +import { isBoolean } from '@guardian/libs'; + +isBoolean(''); // false +isBoolean(true); // true +``` diff --git a/libs/@guardian/libs/src/isBoolean/isBoolean.test.ts b/libs/@guardian/libs/src/isBoolean/isBoolean.test.ts new file mode 100644 index 000000000..569e63d8c --- /dev/null +++ b/libs/@guardian/libs/src/isBoolean/isBoolean.test.ts @@ -0,0 +1,28 @@ +import { isBoolean } from './isBoolean'; + +describe('isBoolean', () => { + it('detects a boolean', () => { + expect(isBoolean(true)).toBe(true); + expect(isBoolean(false)).toBe(true); + }); + + it.each([ + '', + null, + undefined, + 123, + Symbol('Sym'), + new Object(), + [], + new Map(), + new Set(), + new WeakMap(), + new WeakSet(), + new Date(), + function () { + return null; + }, + ])('%p is not a boolean', (value) => { + expect(isBoolean(value)).toBe(false); + }); +}); diff --git a/libs/@guardian/libs/src/isBoolean/isBoolean.ts b/libs/@guardian/libs/src/isBoolean/isBoolean.ts new file mode 100644 index 000000000..d115ebd6f --- /dev/null +++ b/libs/@guardian/libs/src/isBoolean/isBoolean.ts @@ -0,0 +1,3 @@ +export const isBoolean = (_: unknown): _ is boolean => { + return typeof _ === 'boolean'; +}; diff --git a/libs/@guardian/libs/src/isObject/README.md b/libs/@guardian/libs/src/isObject/README.md new file mode 100644 index 000000000..55dade871 --- /dev/null +++ b/libs/@guardian/libs/src/isObject/README.md @@ -0,0 +1,14 @@ +# `isObject(value)` + +Returns: `boolean` + +Checks whether `value` is a plain object (i.e. `{}`-like). + +## Example + +```js +import { isObject } from '@guardian/libs'; + +isObject({ a: 'a' }); // true +isObject(['a']); // false +``` diff --git a/libs/@guardian/libs/src/isObject/isObject.test.ts b/libs/@guardian/libs/src/isObject/isObject.test.ts new file mode 100644 index 000000000..f1284c31b --- /dev/null +++ b/libs/@guardian/libs/src/isObject/isObject.test.ts @@ -0,0 +1,29 @@ +import { isObject } from './isObject'; + +describe('isObject', () => { + it('detects a valid object', () => { + expect(isObject({})).toBe(true); + expect(isObject(new Object())).toBe(true); + }); + + it.each([ + null, + undefined, + true, + 123, + Symbol('Sym'), + new String(), + '', + [], + new Map(), + new Set(), + new WeakMap(), + new WeakSet(), + new Date(), + function () { + return null; + }, + ])('%p is not a valid object', (value) => { + expect(isObject(value)).toBe(false); + }); +}); diff --git a/libs/@guardian/libs/src/isObject/isObject.ts b/libs/@guardian/libs/src/isObject/isObject.ts new file mode 100644 index 000000000..8c1076925 --- /dev/null +++ b/libs/@guardian/libs/src/isObject/isObject.ts @@ -0,0 +1,13 @@ +// cribbed from https://github.com/sindresorhus/is-plain-obj + +export const isObject = ( + value: unknown, +): value is Record => { + if (Object.prototype.toString.call(value) !== '[object Object]') { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- it's a native method + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype === Object.prototype; +}; diff --git a/libs/@guardian/libs/src/isString/README.md b/libs/@guardian/libs/src/isString/README.md new file mode 100644 index 000000000..959dbe645 --- /dev/null +++ b/libs/@guardian/libs/src/isString/README.md @@ -0,0 +1,14 @@ +# `isString(value)` + +Returns: `boolean` + +Checks whether `value` is a string. + +## Example + +```js +import { isString } from '@guardian/libs'; + +isString('hello'); // true +isString(54321); // false +``` diff --git a/libs/@guardian/libs/src/isString/isString.test.ts b/libs/@guardian/libs/src/isString/isString.test.ts new file mode 100644 index 000000000..6666c11fd --- /dev/null +++ b/libs/@guardian/libs/src/isString/isString.test.ts @@ -0,0 +1,28 @@ +import { isString } from './isString'; + +describe('isString', () => { + it('detects a valid string', () => { + expect(isString('hello')).toBe(true); + expect(isString(new String())).toBe(true); + }); + + it.each([ + null, + undefined, + true, + 123, + Symbol('Sym'), + new Object(), + [], + new Map(), + new Set(), + new WeakMap(), + new WeakSet(), + new Date(), + function () { + return null; + }, + ])('%p is not a valid string', (value) => { + expect(isString(value)).toBe(false); + }); +}); diff --git a/libs/@guardian/libs/src/isString/isString.ts b/libs/@guardian/libs/src/isString/isString.ts new file mode 100644 index 000000000..8c650904a --- /dev/null +++ b/libs/@guardian/libs/src/isString/isString.ts @@ -0,0 +1,3 @@ +export const isString = (_: unknown): _ is string => { + return Object.prototype.toString.call(_) === '[object String]'; +}; diff --git a/libs/@guardian/libs/src/isUndefined/README.md b/libs/@guardian/libs/src/isUndefined/README.md new file mode 100644 index 000000000..1b71fdb74 --- /dev/null +++ b/libs/@guardian/libs/src/isUndefined/README.md @@ -0,0 +1,17 @@ +# `isUndefined(value)` + +Returns: `boolean` + +Checks whether `value` is `undefined`. + +## Example + +```js +import { isUndefined } from '@guardian/libs'; + +let a; +let b = 'I am defined!'; + +isUndefined(a); // true +isUndefined(b); // false +``` diff --git a/libs/@guardian/libs/src/isUndefined/isUndefined.test.ts b/libs/@guardian/libs/src/isUndefined/isUndefined.test.ts new file mode 100644 index 000000000..33f134c1f --- /dev/null +++ b/libs/@guardian/libs/src/isUndefined/isUndefined.test.ts @@ -0,0 +1,30 @@ +import { isUndefined } from './isUndefined'; + +let a: undefined; + +describe('isUndefined', () => { + it('detects a valid string', () => { + expect(isUndefined(a)).toBe(true); + }); + + it.each([ + null, + 'hello', + new String(), + true, + 123, + Symbol('Sym'), + new Object(), + [], + new Map(), + new Set(), + new WeakMap(), + new WeakSet(), + new Date(), + function () { + return null; + }, + ])('%p is not undefined', (value) => { + expect(isUndefined(value)).toBe(false); + }); +}); diff --git a/libs/@guardian/libs/src/isUndefined/isUndefined.ts b/libs/@guardian/libs/src/isUndefined/isUndefined.ts new file mode 100644 index 000000000..b05f22aa7 --- /dev/null +++ b/libs/@guardian/libs/src/isUndefined/isUndefined.ts @@ -0,0 +1,3 @@ +// the lodash way https://github.com/lodash/lodash/blob/master/isUndefined.js + +export const isUndefined = (_: unknown): _ is undefined => _ === undefined; diff --git a/libs/@guardian/libs/src/joinUrl/README.md b/libs/@guardian/libs/src/joinUrl/README.md new file mode 100644 index 000000000..d1517f9ae --- /dev/null +++ b/libs/@guardian/libs/src/joinUrl/README.md @@ -0,0 +1,16 @@ +# `joinUrl` + +Returns: `string` + +Function that takes a variable number of strings as arguments, joining them as a single valid URL string. + +Handles trailing or leading spaces and double slashes. + +## Example + +```js +import { joinUrl } from '@guardian/libs'; + +const url = joinUrl('http://example.com/ ', ' /abc/', '/xyz/'); +// 'http://example.com/abc/xyz' +``` diff --git a/libs/@guardian/libs/src/joinUrl/joinUrl.test.ts b/libs/@guardian/libs/src/joinUrl/joinUrl.test.ts new file mode 100644 index 000000000..b1141f7eb --- /dev/null +++ b/libs/@guardian/libs/src/joinUrl/joinUrl.test.ts @@ -0,0 +1,48 @@ +import { joinUrl } from './joinUrl'; + +describe('joinUrl', () => { + it('prevents double slashes', () => { + expect(joinUrl('http://example.com/', '/abc/', '/xyz')).toBe( + 'http://example.com/abc/xyz', + ); + }); + it('preserves trailing slashes', () => { + expect(joinUrl('http://example.com/', '/xyz/')).toBe( + 'http://example.com/xyz/', + ); + }); + + it('adds slashes if none are present', () => { + expect(joinUrl('http://example.com', 'abc', 'xyz')).toBe( + 'http://example.com/abc/xyz', + ); + }); + + it('deals with combinations of slash present and not', () => { + expect(joinUrl('http://example.com/', 'abc/', '/xyz')).toBe( + 'http://example.com/abc/xyz', + ); + }); + + it('works with implicit protocol urls', () => { + expect(joinUrl('//example.com/', '/index.html')).toBe( + '//example.com/index.html', + ); + }); + + it('returns an empty string on empty input', () => { + expect(joinUrl()).toBe(''); + }); + + it('works with a filename', () => { + expect(joinUrl('/AudioAtomWrapper.js')).toBe('/AudioAtomWrapper.js'); + }); + + it('works when the filename has special characters in it', () => { + expect( + joinUrl( + '/vendors~AudioAtomWrapper~elements-YoutubeBlockComponent.js', + ), + ).toBe('/vendors~AudioAtomWrapper~elements-YoutubeBlockComponent.js'); + }); +}); diff --git a/libs/@guardian/libs/src/joinUrl/joinUrl.ts b/libs/@guardian/libs/src/joinUrl/joinUrl.ts new file mode 100644 index 000000000..45ff34f7e --- /dev/null +++ b/libs/@guardian/libs/src/joinUrl/joinUrl.ts @@ -0,0 +1,5 @@ +// detect two or more `/` chars after a word boundary +const multipleSlashesInRoute = /\b\/{2,}/g; + +export const joinUrl = (...args: string[]): string => + args.join('/').replace(multipleSlashesInRoute, '/'); diff --git a/libs/@guardian/libs/src/loadScript/README.md b/libs/@guardian/libs/src/loadScript/README.md new file mode 100644 index 000000000..568fd5ce2 --- /dev/null +++ b/libs/@guardian/libs/src/loadScript/README.md @@ -0,0 +1,37 @@ +# `loadScript(src, props?)` + +Returns: `Promise` + +Loads an external JavaScript file by injecting a `script` element into the page. + +Returns a promise that resolves the `load` event once the script has loaded, or rejects with the `Error` if something goes wrong. + +If a script has been loaded already, it will resolve `undefined` immediately. + +### `src` + +Type: `string` + +URL for the script `src`. + +### `props?` + +Type: `object` + +Optional attributes for the `script` element that will be created. Can be any valid `script` attributes other than `src`, `onload` or `onerror`, which will be ignored. + +## Example + +```js +import { loadScript } from '@guardian/libs'; + +loadScript('my-polyfills.js', { async: false }); + +loadScript('my-functions.js') + .then(() => { + // do something now that my-functions.js has loaded + }) + .catch(() => { + // do something if my-functions.js script fails to load + }); +``` diff --git a/libs/@guardian/libs/src/loadScript/loadScript.test.ts b/libs/@guardian/libs/src/loadScript/loadScript.test.ts new file mode 100644 index 000000000..591060c5f --- /dev/null +++ b/libs/@guardian/libs/src/loadScript/loadScript.test.ts @@ -0,0 +1,80 @@ +import { loadScript } from './loadScript'; + +const goodURL = 'good-url'; +const badURL = 'bad-url'; + +// mimic script loading events. +// when we detect that a script has been added to the DOM: +// - if the src is goodURL trigger a load event +// - if the src is badURL trigger an error event +new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((addedNode) => { + if (addedNode.nodeName === 'SCRIPT') { + const addedScript = addedNode as HTMLScriptElement; + + if (addedScript.src.includes(goodURL)) { + addedNode.dispatchEvent(new Event('load')); + } + + if (addedScript.src.includes(badURL)) { + addedNode.dispatchEvent(new Event('error')); + } + } + }); + }); +}).observe(document.body, { + childList: true, +}); + +beforeEach(() => { + document.body.innerHTML = ''; + document.body.appendChild(document.createElement('script')); +}); + +describe('loadScript', () => { + it('adds a script to the page and resolves the promise it returns when the script loads', async () => { + expect(document.scripts).toHaveLength(1); + await expect(loadScript(goodURL)).resolves.toMatchObject({ + type: 'load', + }); + expect(document.scripts).toHaveLength(2); + }); + + it('resolves immediately if a script with matching src is already on page and stops there', async () => { + expect(document.scripts).toHaveLength(1); + await expect(loadScript(goodURL)).resolves.toMatchObject({ + type: 'load', + }); + // try injecting it again a random amount of times + await expect(loadScript(goodURL)).resolves.toBeUndefined(); + await expect(loadScript(goodURL)).resolves.toBeUndefined(); + await expect(loadScript(goodURL)).resolves.toBeUndefined(); + expect(document.scripts).toHaveLength(2); + }); + + it('does not inject duplicate scripts if they are called with and without protocol', async () => { + expect(document.scripts).toHaveLength(1); + await expect(loadScript(`//${goodURL}`)).resolves.toMatchObject({ + type: 'load', + }); + // try injecting it again with a full protocol (http-only because we're in jest) + await expect(loadScript(`http://${goodURL}`)).resolves.toBeUndefined(); + expect(document.scripts).toHaveLength(2); + }); + + it('can add scripts with attributes', async () => { + await loadScript(goodURL, { + async: true, + referrerPolicy: 'no-referrer', + className: 'u6ytfiuyoibnpoim', + }); + expect(document.scripts[0]?.async).toBeTruthy(); + expect(document.scripts[0]?.referrerPolicy).toBe('no-referrer'); + expect(document.scripts[0]?.className).toBe('u6ytfiuyoibnpoim'); + }); + + it('rejects if the script fails to load', async () => { + await expect(loadScript(badURL)).rejects.toBeDefined(); + }); +}); diff --git a/libs/@guardian/libs/src/loadScript/loadScript.ts b/libs/@guardian/libs/src/loadScript/loadScript.ts new file mode 100644 index 000000000..88376a668 --- /dev/null +++ b/libs/@guardian/libs/src/loadScript/loadScript.ts @@ -0,0 +1,35 @@ +/** + * Loads an external JavaScript file by injecting a `script` element into the page. + * + * Returns a promise that resolves once the script has loaded, or rejects if something goes wrong. + * + * If a script has been loaded already, it will resolve immediately. + * + * @param src - URL for the script `src` + * @param props - any valid `script` attributes other than `src`, `onload` or `onerror` + */ + +export const loadScript = ( + src: string, + props?: Omit, 'src' | 'onload' | 'onerror'>, +): Promise => + new Promise((resolve, reject) => { + // creating this before the check below allows us to compare the resolved `src` values + const script = document.createElement('script'); + script.src = src; + + // dont inject 2 scripts with the same src + if ( + Array.from(document.scripts).some(({ src }) => script.src === src) + ) { + return resolve(void 0); + } + + Object.assign(script, props); + + script.onload = resolve; + script.onerror = reject; + + const ref = document.scripts[0]; + ref?.parentNode?.insertBefore(script, ref); + }); diff --git a/libs/@guardian/libs/src/locale/README.md b/libs/@guardian/libs/src/locale/README.md new file mode 100644 index 000000000..b448a744d --- /dev/null +++ b/libs/@guardian/libs/src/locale/README.md @@ -0,0 +1,23 @@ +# `getLocale` + +Returns: `Promise` + +> See [`CountryCode`](../countries#countrycode-1) docs for more info. + +Fetches the user's current location as an [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Decoding_table) string e.g. `'GB'`, `'AU'` etc. + +Lookups are cached, so you can call this function as many times as you want without worrying about performance. + +## Example + +```js +import { getLocale } from '@guardian/libs'; + +getLocale().then((locale) => { + console.log(locale); // UK, AU etc +}); +``` + +### Overrides + +If you want to override the actual locale (e.g. in CI or development), set a `localStorage` item of `gu.geo.override` to the country code you need. diff --git a/libs/@guardian/libs/src/locale/getLocale.test.ts b/libs/@guardian/libs/src/locale/getLocale.test.ts new file mode 100644 index 000000000..0211d8088 --- /dev/null +++ b/libs/@guardian/libs/src/locale/getLocale.test.ts @@ -0,0 +1,74 @@ +import fetchMock from 'jest-fetch-mock'; +import * as getCookieForSpy from '../cookies/getCookie'; +import { getCookie } from '../cookies/getCookie'; +import { removeCookie } from '../cookies/removeCookie'; +import { setSessionCookie } from '../cookies/setSessionCookie'; +import { storage } from '../storage/storage'; +import { __resetCachedValue, getLocale } from './getLocale'; + +const KEY = 'GU_geo_country'; +const KEY_OVERRIDE = 'gu.geo.override'; + +fetchMock.enableMocks(); + +describe('getLocale', () => { + beforeEach(() => { + storage.local.clear(); + removeCookie({ name: KEY }); + __resetCachedValue(); + }); + + it('returns overridden locale if it exists', async () => { + storage.local.set(KEY_OVERRIDE, 'CY'); + setSessionCookie({ name: KEY, value: 'GB' }); + const locale = await getLocale(); + expect(locale).toBe('CY'); + }); + + it('gets a stored valid locale', async () => { + setSessionCookie({ name: KEY, value: 'CY' }); + const locale = await getLocale(); + expect(locale).toBe('CY'); + }); + + it('fetches the remote value if cookie is missing', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ country: 'CZ' })); + const locale = await getLocale(); + expect(locale).toBe('CZ'); + expect(getCookie({ name: KEY })).toBe('CZ'); + }); + + it('ignores a stored invalid locale', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ country: 'CZ' })); + setSessionCookie({ name: KEY, value: 'outerspace' }); + const locale = await getLocale(); + expect(locale).toBe('CZ'); + expect(getCookie({ name: KEY })).toBe('CZ'); + }); + + it('ignores an invalid remote response', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ country: 'outerspace' })); + const locale = await getLocale(); + expect(locale).toBeNull(); + expect(getCookie({ name: KEY })).toBeNull(); + }); + + it('ignores an error in the remote response', async () => { + fetchMock.mockResponseOnce('regregergreg'); + const locale = await getLocale(); + expect(locale).toBeNull(); + expect(getCookie({ name: KEY })).toBeNull(); + }); + + it('uses the cached value if available', async () => { + const spy = jest.spyOn(getCookieForSpy, 'getCookie'); + + setSessionCookie({ name: KEY, value: 'CY' }); + const locale = await getLocale(); + const locale2 = await getLocale(); + + expect(locale).toBe(locale2); + expect(spy).toHaveBeenCalledTimes(1); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/@guardian/libs/src/locale/getLocale.ts b/libs/@guardian/libs/src/locale/getLocale.ts new file mode 100644 index 000000000..1e4730bd9 --- /dev/null +++ b/libs/@guardian/libs/src/locale/getLocale.ts @@ -0,0 +1,60 @@ +import { getCookie } from '../cookies/getCookie'; +import { setSessionCookie } from '../cookies/setSessionCookie'; +import type { CountryCode } from '../countries/@types/CountryCode'; +import { isString } from '../isString/isString'; +import { storage } from '../storage/storage'; + +const KEY = 'GU_geo_country'; +const KEY_OVERRIDE = 'gu.geo.override'; +const URL = 'https://api.nextgen.guardianapps.co.uk/geolocation'; +const COUNTRY_REGEX = /^[A-Z]{2}$/; + +// best guess that we have a valid code, without actually shipping the entire list +const isValidCountryCode = (country: unknown) => + isString(country) && COUNTRY_REGEX.test(country); + +// we'll cache any successful lookups so we only have to do this once +let locale: CountryCode | undefined; + +// just used for tests +export const __resetCachedValue = (): void => (locale = void 0); + +/** + * Fetches the user's current location as an ISO 3166-1 alpha-2 string e.g. 'GB', 'AU' etc + */ + +export const getLocale = async (): Promise => { + if (locale) return locale; + + // return overridden geo from localStorage, used for changing geo only for development purposes + const geoOverride = storage.local.get(KEY_OVERRIDE) as CountryCode; + + if (isValidCountryCode(geoOverride)) { + return (locale = geoOverride); + } + + // return locale from cookie if it exists + const stored = getCookie({ name: 'GU_geo_country' }); + + if (stored && isValidCountryCode(stored)) { + return (locale = stored as CountryCode); + } + + // use our API to get one + try { + const { country } = (await fetch(URL).then((response) => + response.json(), + )) as { country: CountryCode }; + + if (isValidCountryCode(country)) { + setSessionCookie({ name: KEY, value: country }); + + // return it + return (locale = country); + } + } catch (e) { + // do nothing + } + + return null; +}; diff --git a/libs/@guardian/libs/src/logger/@types/logger.ts b/libs/@guardian/libs/src/logger/@types/logger.ts new file mode 100644 index 000000000..dd9b1353d --- /dev/null +++ b/libs/@guardian/libs/src/logger/@types/logger.ts @@ -0,0 +1,7 @@ +import type { commonStyle, teamStyles } from '../teamStyles'; + +export type Teams = Record>; +export type LogCall = (team: TeamName, ...args: unknown[]) => void; +export type TeamSubscription = (arg: TeamName) => void; +export type TeamStyle = TeamName | keyof typeof commonStyle; +export type TeamName = keyof typeof teamStyles; diff --git a/libs/@guardian/libs/src/logger/README.md b/libs/@guardian/libs/src/logger/README.md new file mode 100644 index 000000000..e970c01ff --- /dev/null +++ b/libs/@guardian/libs/src/logger/README.md @@ -0,0 +1,92 @@ +# `log`, `debug` + +Selectively log team-specific messages to the console. + +### Example + +```js +// my-file.js + +import { log } from '@guardian/libs'; + +log('commercial', { 1: true, 2: false }); +``` + +Then in the browser console, you can do: + +```js +window.guardian.logger.teams(); +> ['commercial', 'cmp', 'dotcom']; + +window.guardian.logger.subscribeTo('commercial'); +window.guardian.logger.unsubscribeFrom('cmp'); +``` + +and see + +![example branded console output](/static/logger.svg) + +## Table of contents + +- [Methods](#methods) + - [`log(team, args)`](#logteam-args) + - [`debug(team, args)`](#debugteam-args) +- [Browser globals](#browser-globals) + - [`window.guardian.logger.subscribeTo(team)`](#windowguardianloggersubscribetoteam) + - [`window.guardian.logger.unsubscribeFrom(team)`](#windowguardianloggerunsubscribefromteam) + - [`window.guardian.logger.teams()`](#windowguardianloggerteams) + +## Methods + +### `log(team, args)` + +Returns: `void` + +Logs a message to the console for a specific team. + +#### `team` + +Type: `string`
+ +Name of the team interested in this log. + +#### `args` + +Type: `any`
+ +The content to `console.log`. + +#### Example + +```js +log('commercial', { 1: true, 2: false }); +``` + +### `debug(team, args)` + +Returns: `void` + +Identical to [`log`][], but only runs in non-production environments (including CODE). + +## Browser globals + +### `window.guardian.logger.teams()` + +Returns: `Array` + +Get a list of available teams. + +### `window.guardian.logger.subscribeTo(team)` + +Returns: `void` + +Start receiving logs for a specific team. + +### `window.guardian.logger.unsubscribeFrom(team)` + +Returns: `void` + +Stop receiving logs for a specific team. + +[`log`]: #logteam-args +[`debug`]: #debugteam-args diff --git a/libs/@guardian/libs/src/logger/debug.ts b/libs/@guardian/libs/src/logger/debug.ts new file mode 100644 index 000000000..6e0ac0ada --- /dev/null +++ b/libs/@guardian/libs/src/logger/debug.ts @@ -0,0 +1,13 @@ +import type { LogCall } from './@types/logger'; +import { log } from './log'; + +/** + * Only logs in dev environments. + */ + +export const debug: LogCall = (team, ...args) => { + const isNotProd = window.location.origin !== 'https://www.theguardian.com'; + if (isNotProd) { + log(team, ...args); + } +}; diff --git a/libs/@guardian/libs/src/logger/log.ts b/libs/@guardian/libs/src/logger/log.ts new file mode 100644 index 000000000..36f0d2d63 --- /dev/null +++ b/libs/@guardian/libs/src/logger/log.ts @@ -0,0 +1,67 @@ +import { isString } from '../isString/isString'; +import { storage } from '../storage/storage'; +import type { + LogCall, + TeamName, + TeamStyle, + TeamSubscription, +} from './@types/logger'; +import { STORAGE_KEY } from './storage-key'; +import { commonStyle, isTeam, teamStyles } from './teamStyles'; + +const allStyles = { ...teamStyles, ...commonStyle }; + +const messageStyle = (teamStyle: TeamStyle): string => { + const { background, font } = allStyles[teamStyle]; + return `background: ${background}; color: ${font}; padding: 2px 3px; border-radius:3px`; +}; + +const getTeamSubscriptions = (): TeamName[] => { + const teams: unknown = storage.local.get(STORAGE_KEY); + if (!isString(teams)) return []; + return teams.split(',').filter(isTeam); +}; + +/** + * Subscribe to a team’s log + * @param team the team’s unique ID + */ +const subscribeTo: TeamSubscription = (team) => { + const teamSubscriptions: string[] = getTeamSubscriptions(); + if (!teamSubscriptions.includes(team)) teamSubscriptions.push(team); + storage.local.set(STORAGE_KEY, teamSubscriptions.join(',')); + log(team, '🔔 Subscribed, hello!'); +}; + +/** + * Unsubscribe to a team’s log + * @param team the team’s unique ID + */ +const unsubscribeFrom: TeamSubscription = (team) => { + log(team, '🔕 Unsubscribed, good-bye!'); + const teamSubscriptions: string[] = getTeamSubscriptions().filter( + (t) => t !== team, + ); + storage.local.set(STORAGE_KEY, teamSubscriptions.join(',')); +}; + +/* istanbul ignore next */ +if (typeof window !== 'undefined') { + window.guardian ||= {}; + window.guardian.logger ||= { + subscribeTo, + unsubscribeFrom, + teams: () => Object.keys(teamStyles), + }; +} + +/** + * Runs in all environments, if local storage values are set. + */ +export const log: LogCall = (team, ...args) => { + if (!getTeamSubscriptions().includes(team)) return; + + const styles = [messageStyle('common'), '', messageStyle(team), '']; + + console.log(`%c@guardian%c %c${team}%c`, ...styles, ...args); +}; diff --git a/libs/@guardian/libs/src/logger/logger.test.ts b/libs/@guardian/libs/src/logger/logger.test.ts new file mode 100644 index 000000000..47ee8d2b6 --- /dev/null +++ b/libs/@guardian/libs/src/logger/logger.test.ts @@ -0,0 +1,134 @@ +import { hex } from 'wcag-contrast'; +import { storage } from '../storage/storage'; +import type { TeamName } from './@types/logger'; +import { debug } from './debug'; +import { log } from './log'; +import { STORAGE_KEY } from './storage-key'; +import { teamStyles } from './teamStyles'; + +const spy = jest + .spyOn(console, 'log') + .mockImplementation(() => () => undefined); + +const consoleMessage = (): string | undefined => { + if (spy.mock.calls[0] && typeof spy.mock.calls[0][5] === 'string') { + return spy.mock.calls[0][5]; + } + return undefined; +}; + +describe('Logs messages for a team', () => { + it(`should not log any messages by default`, () => { + log('cmp', 'this will not log'); + log('commercial', 'neither will this'); + log('dotcom', 'or this'); + expect(consoleMessage()).toBeUndefined(); + }); + + const message = 'Hello, world!'; + const team = 'cmp'; + + it(`should be able to add team ${team}`, () => { + if (window.guardian?.logger) window.guardian.logger.subscribeTo(team); + const registered: string = storage.local.get(STORAGE_KEY) as string; + expect(registered).toBe(team); + }); + it(`should log ${message} for team ${team}`, () => { + log(team, message); + expect(consoleMessage()).toBe(message); + }); + + it('should log debug messages in dev', () => { + debug(team, message); + expect(consoleMessage()).toBe(message); + }); + + it('should not log debug messages in prod', () => { + //@ts-expect-error -- we’re modifying the window + delete window.location; + //@ts-expect-error -- we only check window.location.origin + window.location = new URL('https://www.theguardian.com'); + + debug(team, message); + expect(consoleMessage()).toBe(undefined); + }); +}); + +describe('Add and remove teams', () => { + it(`should first clear local storage`, () => { + storage.local.clear(); + expect(storage.local.get(STORAGE_KEY)).toBe(null); + }); + it(`should be able to add two teams`, () => { + if (window.guardian?.logger) { + window.guardian.logger.subscribeTo('commercial'); + window.guardian.logger.subscribeTo('dotcom'); + } + const registered: string = storage.local.get(STORAGE_KEY) as string; + expect(registered).toBe('commercial,dotcom'); + }); + + it(`should be able to add a third team`, () => { + if (window.guardian?.logger) window.guardian.logger.subscribeTo('cmp'); + const registered: string = storage.local.get(STORAGE_KEY) as string; + expect(registered).toBe('commercial,dotcom,cmp'); + }); + + it(`should not add teams more than once`, () => { + if (window.guardian?.logger) { + window.guardian.logger.subscribeTo('cmp'); + window.guardian.logger.subscribeTo('dotcom'); + window.guardian.logger.subscribeTo('dotcom'); + window.guardian.logger.subscribeTo('commercial'); + } + const registered: string = storage.local.get(STORAGE_KEY) as string; + expect(registered).toBe('commercial,dotcom,cmp'); + }); + + it(`should be able to remove a third team`, () => { + if (window.guardian?.logger) { + window.guardian.logger.unsubscribeFrom('cmp'); + } + const registered: string = storage.local.get(STORAGE_KEY) as string; + expect(registered).toBe('commercial,dotcom'); + }); + + it(`should be able to remove a team`, () => { + if (window.guardian?.logger) { + window.guardian.logger.unsubscribeFrom('commercial'); + } + const registered: string = storage.local.get(STORAGE_KEY) as string; + expect(registered).toBe('dotcom'); + }); + + it('should return the list of registered teams', () => { + const teams = window.guardian?.logger?.teams(); + expect(Array.isArray(teams)).toBe(true); + expect(teams).toContain('cmp'); + }); +}); + +describe('Team-based logging', () => { + const teams: TeamName[] = ['cmp', 'commercial', 'dotcom']; + + it.each(teams)(`should only log message for team: %s`, (team) => { + storage.local.set(STORAGE_KEY, team); + + teams.map((t) => { + log(t, `a message for ${t}`); + }); + expect(consoleMessage()).toBe(`a message for ${team}`); + }); +}); + +describe('Ensure labels are accessible', () => { + it.each(Object.entries(teamStyles))( + 'should have a minimum contrast ratio of 4.5 (AA) for %s', + (_, colour) => { + const { font, background } = colour; + const ratio = hex(font, background); + + expect(ratio).toBeGreaterThanOrEqual(4.5); + }, + ); +}); diff --git a/libs/@guardian/libs/src/logger/storage-key.ts b/libs/@guardian/libs/src/logger/storage-key.ts new file mode 100644 index 000000000..cb802c383 --- /dev/null +++ b/libs/@guardian/libs/src/logger/storage-key.ts @@ -0,0 +1 @@ +export const STORAGE_KEY = 'gu.logger'; diff --git a/libs/@guardian/libs/src/logger/teamStyles.ts b/libs/@guardian/libs/src/logger/teamStyles.ts new file mode 100644 index 000000000..75dc1cd20 --- /dev/null +++ b/libs/@guardian/libs/src/logger/teamStyles.ts @@ -0,0 +1,51 @@ +import type { TeamName } from './@types/logger'; + +/** Common Guardian blue label. Do not edit */ +const commonStyle = { + common: { + background: '#052962', + font: '#ffffff', + }, +} as const; + +/** + * You can only subscribe to teams from this list. + * Add your team name below to start logging. + * + * Make sure your label has a contrast ratio of 4.5 or more. + * */ +const teamStyles = { + commercial: { + background: '#77EEAA', + font: '#004400', + }, + cmp: { + background: '#FF6BB5', + font: '#2F0404', + }, + dotcom: { + background: '#000000', + font: '#ff7300', + }, + design: { + background: '#185E36', + font: '#FFF4F2', + }, + tx: { + background: '#2F4F4F', + font: '#FFFFFF', + }, + supporterRevenue: { + background: '#0F70B7', + font: '#ffffff', + }, + identity: { + background: '#6F5F8F', + font: '#ffffff', + }, +} as const; + +const isTeam = (team: string): team is TeamName => + Object.keys(teamStyles).includes(team); + +export { commonStyle, teamStyles, isTeam }; diff --git a/libs/@guardian/libs/src/ophan/@types/index.ts b/libs/@guardian/libs/src/ophan/@types/index.ts new file mode 100644 index 000000000..1ed477240 --- /dev/null +++ b/libs/@guardian/libs/src/ophan/@types/index.ts @@ -0,0 +1,112 @@ +/** + * an individual A/B test, structured for Ophan + */ +export type OphanABEvent = { + variantName: string; + complete: string | boolean; + campaignCodes?: string[]; +}; + +/** + * the actual payload we send to Ophan: an object of OphanABEvents with test IDs as keys + */ +export type OphanABPayload = { + abTestRegister: Record; +}; + +export type OphanProduct = + | 'CONTRIBUTION' + | 'RECURRING_CONTRIBUTION' + | 'MEMBERSHIP_SUPPORTER' + | 'MEMBERSHIP_PATRON' + | 'MEMBERSHIP_PARTNER' + | 'DIGITAL_SUBSCRIPTION' + | 'PRINT_SUBSCRIPTION'; + +export type OphanAction = + | 'INSERT' + | 'VIEW' + | 'EXPAND' + | 'LIKE' + | 'DISLIKE' + | 'SUBSCRIBE' + | 'ANSWER' + | 'VOTE' + | 'CLICK' + | 'ACCEPT_DEFAULT_CONSENT' + | 'MANAGE_CONSENT' + | 'CONSENT_ACCEPT_ALL' + | 'CONSENT_REJECT_ALL' + | 'STICK' + | 'CLOSE' + | 'RETURN' + | 'SIGN_IN' + | 'CREATE_ACCOUNT'; + +export type OphanComponentType = + | 'READERS_QUESTIONS_ATOM' + | 'QANDA_ATOM' + | 'PROFILE_ATOM' + | 'GUIDE_ATOM' + | 'TIMELINE_ATOM' + | 'NEWSLETTER_SUBSCRIPTION' + | 'SURVEYS_QUESTIONS' + | 'ACQUISITIONS_EPIC' + | 'ACQUISITIONS_ENGAGEMENT_BANNER' + | 'ACQUISITIONS_THANK_YOU_EPIC' + | 'ACQUISITIONS_HEADER' + | 'ACQUISITIONS_FOOTER' + | 'ACQUISITIONS_INTERACTIVE_SLICE' + | 'ACQUISITIONS_NUGGET' + | 'ACQUISITIONS_STANDFIRST' + | 'ACQUISITIONS_THRASHER' + | 'ACQUISITIONS_EDITORIAL_LINK' + | 'ACQUISITIONS_SUBSCRIPTIONS_BANNER' + | 'ACQUISITIONS_OTHER' + | 'SIGN_IN_GATE' + | 'RETENTION_ENGAGEMENT_BANNER' + | 'RETENTION_EPIC' + | 'CONSENT' + | 'LIVE_BLOG_PINNED_POST' + | 'STICKY_VIDEO' + | 'IDENTITY_AUTHENTICATION'; + +export type OphanComponent = { + componentType: OphanComponentType; + id?: string; + products?: OphanProduct[]; + campaignCode?: string; + labels?: string[]; +}; + +export type OphanComponentEvent = { + component: OphanComponent; + action: OphanAction; + value?: string; + id?: string; + abTest?: { + name: string; + variant: string; + }; +}; + +export type OphanABTestMeta = { + abTestName: string; + abTestVariant: string; + campaignCode: string; + campaignId?: string; + componentType: OphanComponentType; + products?: OphanProduct[]; +}; + +/** + * Record any data object to Ophan. + * + * Typically exposed on `window.guardian.ophan.record` + * + * Source: [`ophan/transmit.coffee#L27-L32`](https://github.com/guardian/ophan/blob/ccaa57bcee3f5f3f83ec28973074c9b3f98f1438/tracker-js/assets/coffee/ophan/transmit.coffee#L27-L32) + */ +export type OphanRecordFunction = ( + data: Record, + callback?: () => void, +) => void; diff --git a/libs/@guardian/libs/src/ophan/README.md b/libs/@guardian/libs/src/ophan/README.md new file mode 100644 index 000000000..fbf691ba3 --- /dev/null +++ b/libs/@guardian/libs/src/ophan/README.md @@ -0,0 +1,28 @@ +# Ophan + +Types related to Ophan. + +Ophan has an official type system described in [thrift](https://github.com/guardian/ophan/tree/abb022b43a1fa3922a6cf24478c4a8982cd13b79/event-model/src/main/thrift). In particular, updates in [componentevent.thrift](https://github.com/guardian/ophan/blob/abb022b43a1fa3922a6cf24478c4a8982cd13b79/event-model/src/main/thrift/componentevent.thrift) should be mirrored in this repository. + +## Example + +```js +import type { + OphanABEvent, + OphanABPayload, + OphanAction, + OphanComponent, + OphanComponentEvent, + OphanComponentType, + OphanProduct, + OphanABTestMeta, +} from '@guardian/libs'; +``` + +## `OphanABEvent` + +An individual A/B test, structured for Ophan. + +## `OphanABPayload` + +The payload we send to Ophan: an object of `OphanABEvents` with test IDs as keys. diff --git a/libs/@guardian/libs/src/storage/README.md b/libs/@guardian/libs/src/storage/README.md new file mode 100644 index 000000000..5f933e381 --- /dev/null +++ b/libs/@guardian/libs/src/storage/README.md @@ -0,0 +1,132 @@ +# `storage` + +Robust API over `localStorage` and `sessionStorage`. + +### Example + +```js +import { storage } from '@guardian/libs'; + +// the following are now available: +// - storage.local +// - storage.session +``` + +Has a few advantages over the native API: + +- fails gracefully if storage is not available +- you can save and retrieve any JSONable data +- stored items can expire + +_n.b. the examples below use `storage.local`, but all methods are available for both `storage.local` and `storage.session`._ + +## Methods + +- [`get(key)`](#getkey) +- [`set(key, value, expires?)`](#setkey-value-expires) +- [`remove(key)`](#removekey) +- [`clear()`](#clear) +- [`isAvailable()`](#isavailable) + +## `get(key)` + +Returns: `unknown` + +Retrieves an item from storage. + +#### `key` + +Type: `string`
+ +Name of the stored item. + +### Example + +```js +storage.local.get('my-item'); +``` + +## `set(key, value, expires?)` + +Returns: `void` + +Saves a value to storage. + +#### `key` + +Type: `string` + +Name of the item to store the value in. + +#### `value` + +Type: `string` | `number` | `boolean` | `null` | `object` | `array` + +The value to store. + +#### `expires?` + +Type: `string` | `number` | `Date` + +Optional expiry date for this item. + +### Example + +```js +storage.local.set('my-item', { + prop1: 'abc', + prop2: 123, +}); + +storage.local.set( + 'my-expiring-item', + { + prop1: 'abc', + prop2: 123, + }, + // expires 24 hours from now + Date.now() + 60 * 60 * 24 * 1000, +); +``` + +## `remove(key)` + +Returns: `void` + +Removes an item from storage. + +#### `key` + +Type: `string` + +Name of the stored item to remove. + +### Example + +```js +storage.local.remove('my-item'); +``` + +## `clear()` + +Returns: `void` + +Removes all items from storage. + +### Example + +```js +storage.local.clear(); +``` + +## `isAvailable()` + +Returns: `boolean` + +Check whether the storage type is available. + +### Example + +```js +storage.local.isAvailable(); // true or false +``` diff --git a/libs/@guardian/libs/src/storage/storage.test.ts b/libs/@guardian/libs/src/storage/storage.test.ts new file mode 100644 index 000000000..c736b64b4 --- /dev/null +++ b/libs/@guardian/libs/src/storage/storage.test.ts @@ -0,0 +1,134 @@ +import { storage } from './storage'; + +function functionThatThrowsAnError() { + throw new Error('bang'); +} + +type StorageName = 'local' | 'session'; +const LOCAL: StorageName = 'local'; +const SESSION: StorageName = 'session'; + +describe.each([ + [LOCAL, storage[LOCAL], globalThis.localStorage], + [SESSION, storage[SESSION], globalThis.sessionStorage], +])('storage.%s', (name, implementation, native) => { + let getSpy: jest.SpyInstance; + let setSpy: jest.SpyInstance; + + beforeEach(() => { + getSpy = jest.spyOn(native.__proto__, 'getItem'); + setSpy = jest.spyOn(native.__proto__, 'setItem'); + jest.resetModules(); + native.clear(); + }); + + afterEach(() => { + getSpy.mockRestore(); + setSpy.mockRestore(); + }); + + it(`detects native API availability`, async () => { + expect(implementation.isAvailable()).toBe(true); + + setSpy.mockImplementation(functionThatThrowsAnError); + getSpy.mockImplementation(functionThatThrowsAnError); + + // re-import now we've disabled native storage API + const { storage } = await import('./storage'); + expect(storage[name].isAvailable()).toBe(false); + }); + + it(`is not available if getItem does not return what you setItem`, async () => { + getSpy.mockImplementation(() => '🚫'); + + // re-import now we've fiddled with the native storage API + const { storage } = await import('./storage'); + expect(storage[name].isAvailable()).toBe(false); + }); + + it(`behaves nicely when storage is not available`, async () => { + setSpy.mockImplementation(functionThatThrowsAnError); + getSpy.mockImplementation(functionThatThrowsAnError); + + // re-import now we've disabled native storage API + const { storage } = await import('./storage'); + expect(() => storage[name].set('🚫', true)).not.toThrowError(); + expect(() => storage[name].get('🚫')).not.toThrowError(); + expect(() => storage[name].remove('🚫')).not.toThrowError(); + expect(() => storage[name].getRaw('🚫')).not.toThrowError(); + expect(() => storage[name].setRaw('🚫', '')).not.toThrowError(); + expect(() => storage[name].clear()).not.toThrowError(); + }); + + it.each([ + ['strings', 'a string'], + ['empty strings', ''], + ['objects', { foo: 'bar' }], + ['arrays', [true, 2, 'bar']], + ['true booleans', true], + ['false booleans', false], + ['null', null], + ])('stores and retrieves %s', (type, data) => { + implementation.set(type, data); + expect(native.getItem(type)).toBe(`{"value":${JSON.stringify(data)}}`); + expect(implementation.get(type)).toEqual(data); + }); + + it(`does not return a non-existing item`, () => { + expect(implementation.get('thisDoesNotExist')).toBeNull(); + }); + + it(`does not return an expired item`, () => { + implementation.set('iAmExpired', 'data', new Date('1901-01-01')); + expect(implementation.get('iAmExpired')).toBeNull(); + + // check it's been deleted too + expect(native.getItem('iAmExpired')).toBeNull(); + }); + + it(`return null for a malformed item`, () => { + native.setItem('malformed', '[]'); + expect(implementation.get('malformed')).toBeNull(); + }); + + it(`returns a non-expired item`, () => { + implementation.set('iAmNotExpired', 'data', new Date('2040-01-01')); + expect(implementation.get('iAmNotExpired')).toBeTruthy(); + }); + + it(`deletes items`, () => { + native.setItem('deleteMe', 'please delete me'); + expect(native.getItem('deleteMe')).toBeTruthy(); + + implementation.remove('deleteMe'); + expect(native.getItem('deleteMe')).toBeNull(); + }); + + it(`clears all items`, () => { + native.setItem('deleteMe', 'please delete me'); + native.setItem('deleteMe2', 'please delete me too'); + + implementation.clear(); + + expect(native.getItem('deleteMe')).toBeNull(); + expect(native.getItem('deleteMe2')).toBeNull(); + }); + + it(`gets items in the raw`, async () => { + native.setItem('raw item', '🦁'); + expect(implementation.getRaw('raw item')).toBe('🦁'); + expect(implementation.get('raw item')).toBeNull(); + + setSpy.mockImplementation(functionThatThrowsAnError); + getSpy.mockImplementation(functionThatThrowsAnError); + + // re-import now we've disabled native storage API + const { storage } = await import('./storage'); + expect(storage[name].getRaw('raw item')).toBeNull(); + }); + + it(`sets items in the raw`, () => { + implementation.setRaw('raw item', '🦁'); + expect(native.getItem('raw item')).toBe('🦁'); + }); +}); diff --git a/libs/@guardian/libs/src/storage/storage.ts b/libs/@guardian/libs/src/storage/storage.ts new file mode 100644 index 000000000..cf31d8ede --- /dev/null +++ b/libs/@guardian/libs/src/storage/storage.ts @@ -0,0 +1,128 @@ +import { isObject } from '../isObject/isObject'; +import { isString } from '../isString/isString'; + +class StorageFactory { + #storage: Storage | undefined; // https://mdn.io/Private_class_fields + + constructor(storage: Storage) { + try { + const uid = new Date().toString(); + storage.setItem(uid, uid); + + const available = storage.getItem(uid) == uid; + storage.removeItem(uid); + + if (available) this.#storage = storage; + } catch (e) { + // do nothing + } + } + + /** + * Check whether storage is available. + */ + isAvailable(): boolean { + return Boolean(this.#storage); + } + + /** + * Retrieve an item from storage. + * + * @param key - the name of the item + */ + get(key: string): unknown { + try { + const data: unknown = JSON.parse(this.#storage?.getItem(key) ?? ''); + if (!isObject(data)) return null; + const { value, expires } = data; + + // is this item has passed its sell-by-date, remove it + if (isString(expires) && new Date() > new Date(expires)) { + this.remove(key); + return null; + } + + return value; + } catch (e) { + return null; + } + } + + /** + * Save a value to storage. + * + * @param key - the name of the item + * @param value - the data to save + * @param expires - optional date on which this data will expire + */ + set(key: string, value: unknown, expires?: string | number | Date): void { + return this.#storage?.setItem( + key, + JSON.stringify({ + value, + expires, + }), + ); + } + + /** + * Remove an item from storage. + * + * @param key - the name of the item + */ + remove(key: string): void { + return this.#storage?.removeItem(key); + } + + /** + * Removes all items from storage. + */ + clear(): void { + return this.#storage?.clear(); + } + + /** + * Retrieve an item from storage in its raw state. + * + * @param key - the name of the item + */ + getRaw(key: string): string | null { + return this.#storage?.getItem(key) ?? null; + } + + /** + * Save a raw value to storage. + * + * @param key - the name of the item + * @param value - the data to save + */ + setRaw(key: string, value: string): void { + return this.#storage?.setItem(key, value); + } +} + +/** + * Manages using `localStorage` and `sessionStorage`. + * + * Has a few advantages over the native API, including + * - failing gracefully if storage is not available + * - you can save and retrieve any JSONable data + * + * All methods are available for both `localStorage` and `sessionStorage`. + */ +export const storage = new (class { + #local: StorageFactory | undefined; + #session: StorageFactory | undefined; + + // creating the instance requires testing the native implementation + // which is blocking. therefore, only create new instances of the factory + // when it's accessed i.e. we know we're going to use it + + get local() { + return (this.#local ||= new StorageFactory(localStorage)); + } + + get session() { + return (this.#session ||= new StorageFactory(sessionStorage)); + } +})(); diff --git a/libs/@guardian/libs/src/switches/@types/Switches.ts b/libs/@guardian/libs/src/switches/@types/Switches.ts new file mode 100644 index 000000000..6242f23e8 --- /dev/null +++ b/libs/@guardian/libs/src/switches/@types/Switches.ts @@ -0,0 +1 @@ +export type Switches = Record; diff --git a/libs/@guardian/libs/src/switches/README.md b/libs/@guardian/libs/src/switches/README.md new file mode 100644 index 000000000..14346ed6a --- /dev/null +++ b/libs/@guardian/libs/src/switches/README.md @@ -0,0 +1,19 @@ +# `getSwitches()` + +Returns: `Promise>` + +Supplies the current list of [active switches on theguardian.com](https://frontend.gutools.co.uk/dev/switchboard). + +If `window.guardian.config.switches` exists it will return that. Otherwise it fetches them from https://www.theguardian.com/switches.json. + +## Example + +```js +import { getSwitches } from '@guardian/libs'; + +getSwitches().then((switches) => { + if (switches.mySwitch) { + // do the thing i want + } +}); +``` diff --git a/libs/@guardian/libs/src/switches/getSwitches.test.ts b/libs/@guardian/libs/src/switches/getSwitches.test.ts new file mode 100644 index 000000000..e3de1d198 --- /dev/null +++ b/libs/@guardian/libs/src/switches/getSwitches.test.ts @@ -0,0 +1,54 @@ +import fetchMock from 'jest-fetch-mock'; +import type { Switches } from './@types/Switches'; +import { __resetCachedValue, getSwitches } from './getSwitches'; + +fetchMock.enableMocks(); + +const fixture: Switches = { + switchA: true, + switchB: false, +}; + +describe('getSwitches', () => { + beforeEach(() => { + __resetCachedValue(); + delete window.guardian; + }); + + it('gets switches from window.guardian.config', async () => { + window.guardian = { config: { switches: fixture } }; + const switches = await getSwitches(); + expect(switches).toMatchObject(fixture); + }); + + it('fetches the remote config if local is missing', async () => { + fetchMock.mockResponseOnce(JSON.stringify(fixture)); + const switches = await getSwitches(); + expect(switches).toMatchObject(fixture); + }); + + it('returns an empty object if there are no switches in the system', async () => { + fetchMock.mockResponseOnce(JSON.stringify({})); + const switches = await getSwitches(); + expect(switches).toMatchObject({}); + }); + + it('rejects if the switch config is malformed', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + switches: { badSwitch: 'this is not a boolean' }, + }), + ); + await expect(getSwitches()).rejects.toThrow(); + }); + + it('rejects if the fetch response is malformed', async () => { + fetchMock.mockResponseOnce('rewgrewgwegew'); + await expect(getSwitches()).rejects.toThrow('invalid json'); + }); + + it('rejects if the fetch fails', async () => { + fetchMock.mockRejectOnce(); + await expect(getSwitches()).rejects.toBeUndefined(); + }); +}); diff --git a/libs/@guardian/libs/src/switches/getSwitches.ts b/libs/@guardian/libs/src/switches/getSwitches.ts new file mode 100644 index 000000000..6c8b685cf --- /dev/null +++ b/libs/@guardian/libs/src/switches/getSwitches.ts @@ -0,0 +1,33 @@ +import { isBoolean } from '../isBoolean/isBoolean'; +import { isObject } from '../isObject/isObject'; +import type { Switches } from './@types/Switches'; + +const URL = 'https://www.theguardian.com/switches.json'; + +const validate = (switches: unknown) => + isObject(switches) && Object.values(switches).every(isBoolean); + +const fetchSwitches = () => + fetch(URL) + .then((response) => response.json()) + .then((switches) => + validate(switches) + ? (switches as Switches) + : Promise.reject( + new Error( + 'Error getting remote switches – config is malformed', + ), + ), + ); + +// cache to store any retrieved switches +let switches: Switches | undefined; + +/** + * Get the active guardian switch config + */ + +export const getSwitches = async (): Promise => + (switches ||= window.guardian?.config?.switches ?? (await fetchSwitches())); + +export const __resetCachedValue = (): void => (switches = void 0); diff --git a/libs/@guardian/libs/static/logger.svg b/libs/@guardian/libs/static/logger.svg new file mode 100644 index 000000000..550a0e5ba --- /dev/null +++ b/libs/@guardian/libs/static/logger.svg @@ -0,0 +1,103 @@ + + + +
+
+
Console
+
+ @guardian + commercial + message no.0 + + console.log +
+ @guardian + cmp + message no.1 + + console.log +
+ @guardian + dotcom + message no.2 + + console.log +
+ @guardian + design + message no.3 + + console.log +
+ @guardian + tx + message no.4 + + console.log +
+ @guardian + supporterRevenue + message no.5 + + console.log +
+ @guardian + identity + message no.6 + + console.log +
+
+
+
+
\ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c579939c..3064c3f39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,12 +112,9 @@ importers: '@types/wcag-contrast': 3.0.0 jest-fetch-mock: 3.0.3 mockdate: 3.0.5 - original-libs: npm:@guardian/libs@8.0.0 tslib: 2.4.0 wcag-contrast: 3.0.0 web-vitals: 2.0.0 - dependencies: - original-libs: /@guardian/libs/8.0.0_hlwwgw6ekyqiqbfyexytf76jh4 devDependencies: '@types/wcag-contrast': 3.0.0 jest-fetch-mock: 3.0.3 @@ -1705,16 +1702,6 @@ packages: - supports-color dev: true - /@guardian/libs/8.0.0_hlwwgw6ekyqiqbfyexytf76jh4: - resolution: {integrity: sha512-PRl+zB3/MI3FtnMXa5SEzHDMbMatODbCxynqjRfkrwItWsBgOtC2m8liVRqZGg9IqpliCCGEgJqcy1pnittOBg==} - peerDependencies: - tslib: ^2.4.0 - web-vitals: ^2.0.0 - dependencies: - tslib: 2.4.0 - web-vitals: 2.0.0 - dev: false - /@humanwhocodes/config-array/0.9.5: resolution: {integrity: sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==} engines: {node: '>=10.10.0'} @@ -8979,6 +8966,7 @@ packages: /web-vitals/2.0.0: resolution: {integrity: sha512-aCB1sYxt2eeBufybFRrDQNBg2cOcq2f6Q1He7T+qPHAwpodDXhAoWwBUavwppQQ4kfUcT5eIAfjPc9PdqAxPEw==} + dev: true /webidl-conversions/3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} From add41d29ecd1f1fe64429a6867af9f275aeb28a4 Mon Sep 17 00:00:00 2001 From: Alex Sanders Date: Wed, 31 Aug 2022 16:32:43 +0100 Subject: [PATCH 2/4] use `DocumentVisibilityState` type --- libs/@guardian/libs/src/coreWebVitals/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@guardian/libs/src/coreWebVitals/index.test.ts b/libs/@guardian/libs/src/coreWebVitals/index.test.ts index 624415c9b..3278e6130 100644 --- a/libs/@guardian/libs/src/coreWebVitals/index.test.ts +++ b/libs/@guardian/libs/src/coreWebVitals/index.test.ts @@ -60,7 +60,7 @@ const mockConsoleWarn = jest const spyLog = jest.spyOn(logger, 'log'); -const setVisibilityState = (value: VisibilityState = 'visible') => { +const setVisibilityState = (value: DocumentVisibilityState = 'visible') => { Object.defineProperty(document, 'visibilityState', { writable: true, configurable: true, From ede6453f904691f883ad400c356aa22e97046039 Mon Sep 17 00:00:00 2001 From: Alex Sanders Date: Wed, 31 Aug 2022 16:38:58 +0100 Subject: [PATCH 3/4] update logger preview script for csnx --- libs/@guardian/libs/.lintstagedrc.js | 2 +- .../libs/scripts/generateSvg.logger.teams.ts | 21 ++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/libs/@guardian/libs/.lintstagedrc.js b/libs/@guardian/libs/.lintstagedrc.js index 0beb28f35..87373547c 100644 --- a/libs/@guardian/libs/.lintstagedrc.js +++ b/libs/@guardian/libs/.lintstagedrc.js @@ -1,7 +1,7 @@ const config = require('../../../.lintstagedrc.js'); module.exports = { - 'src/logger/**/*': + 'src/logger/**/*|scripts/generateSvg.logger.teams.*': 'node -r @swc-node/register scripts/generateSvg.logger.teams.ts', '*': 'node -r @swc-node/register scripts/update-readme.ts', ...config, diff --git a/libs/@guardian/libs/scripts/generateSvg.logger.teams.ts b/libs/@guardian/libs/scripts/generateSvg.logger.teams.ts index 463721568..4fbc51dfb 100755 --- a/libs/@guardian/libs/scripts/generateSvg.logger.teams.ts +++ b/libs/@guardian/libs/scripts/generateSvg.logger.teams.ts @@ -1,20 +1,15 @@ -import fs from 'fs'; -import { teamStyles } from '../src/logger/teamStyles'; - -fs.writeFileSync(__dirname + '/../static/logger.svg', generateSvg()); +import fs from 'node:fs'; +import { commonStyle, teamStyles } from '../src/logger/teamStyles'; function generateSvg(): string { - const filteredTeams = Object.entries(teamStyles).filter((team) => { - const [name] = team; - return name !== 'common'; - }); + const teams = Object.entries(teamStyles); const padding = 10; const lineHeight = 24; const width = 600; - const height = filteredTeams.length * lineHeight + padding * 2 + 60; + const height = teams.length * lineHeight + padding * 2 + 60; - const lines = filteredTeams.map((team, index) => { + const lines = teams.map((team, index) => { const [name, colours] = team; return `
@guardian @@ -72,8 +67,8 @@ function generateSvg(): string { } .common { - background-color: ${teamStyles.common.background}; - color: ${teamStyles.common.font}; + background-color: ${commonStyle.common.background}; + color: ${commonStyle.common.font}; } @@ -87,3 +82,5 @@ function generateSvg(): string { `; return svg; } + +fs.writeFileSync(__dirname + '/../static/logger.svg', generateSvg()); From d39ea68ac93a69f9f6315b1314ea1400f313704f Mon Sep 17 00:00:00 2001 From: Alex Sanders Date: Wed, 31 Aug 2022 16:40:58 +0100 Subject: [PATCH 4/4] remove old dev info from readme --- libs/@guardian/libs/README.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/libs/@guardian/libs/README.md b/libs/@guardian/libs/README.md index 1acb0f9b0..3f05a0b54 100644 --- a/libs/@guardian/libs/README.md +++ b/libs/@guardian/libs/README.md @@ -104,24 +104,3 @@ If you are using this library with TypeScript, make sure you are using at least This package uses `ES2020`. If your target environment does not support that, make sure you transpile this package when bundling your application. - -## Development - -### Requirements - -1. [Node LTS (latest)](https://nodejs.org/en/download/) ([nvm](https://github.com/nvm-sh/nvm) or [fnm](https://github.com/Schniz/fnm) recommended) -2. [Yarn](https://classic.yarnpkg.com/en/docs/install/) - -### Releasing - -Changes are automatically released to NPM. - -The `main` branch on GitHub is analysed by [semantic-release](https://semantic-release.gitbook.io/) after every push. - -If a commit message follows the [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0), semantic-release can determine what Types of changes are included in that commit. - -If necessary, it will then automatically release a new, [semver](https://semver.org/)-compliant version of the package to NPM. - -#### Pull requests - -Try to write PR titles in the conventional commit format, and [squash and merge](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/about-pull-request-merges#squash-and-merge-your-pull-request-commits) when merging. That way your PR will trigger a release when you merge it (if necessary).