Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
874 changes: 794 additions & 80 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions web/packages/gravatar/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist

11 changes: 11 additions & 0 deletions web/packages/gravatar/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = {
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
extends: [ 'plugin:@wordpress/eslint-plugin/recommended' ],
plugins: [ 'prettier' ],
rules: {
'prettier/prettier': [ 'error', require( './.prettierrc.js' ) ], // Uses our .prettierrc.js config
},
};
2 changes: 2 additions & 0 deletions web/packages/gravatar/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
21 changes: 21 additions & 0 deletions web/packages/gravatar/.markdownlint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"default": true,
"MD003": {
"style": "atx"
},
"MD007": {
"indent": 4
},
"MD013": {
"line_length": 9999
},
"MD024": {
"allow_different_nesting": true
},
"MD033": {
"allowed_elements": [ "img" ]
},
"MD041": false,
"no-hard-tabs": false,
"whitespace": false
}
4 changes: 4 additions & 0 deletions web/packages/gravatar/.prettierrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
...require( '@wordpress/prettier-config' ),
printWidth: 120,
};
3 changes: 3 additions & 0 deletions web/packages/gravatar/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
presets: [ [ '@babel/env', { loose: true } ], [ '@babel/react', { runtime: 'automatic' } ], '@babel/typescript' ],
};
5 changes: 5 additions & 0 deletions web/packages/gravatar/lint-staged.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
'*.{js,jsx,ts,tsx}': [ () => 'npm run type-check', 'npm run lint:js' ],
'*.md': 'npm run lint:md',
'*.{js,jsx,ts,tsx,json,yaml,yml}': 'npm run format',
};
71 changes: 71 additions & 0 deletions web/packages/gravatar/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"name": "@gravatar-com/gravatar",
"description": "A React component for Gravatar profile images, built on the WordPress UI Avatar.",
"homepage": "https://github.com/Automattic/gravatar/blob/trunk/web/packages/gravatar#readme",
"bugs": "https://github.com/Automattic/gravatar/issues",
"repository": "https://github.com/Automattic/gravatar/blob/trunk/web/packages/gravatar",
"version": "0.0.1",
"author": "Automattic Inc.",
"license": "GPL-2.0-or-later",
"keywords": [
"gravatar",
"avatar",
"react",
"profile"
],
"scripts": {
"test:unit": "wp-scripts test-unit-js",
"test:unit:help": "wp-scripts test-unit-js --help",
"test:unit:watch": "wp-scripts test-unit-js --watch",
"test:unit:debug": "wp-scripts --inspect-brk test-unit-js --runInBand --no-cache",
"format": "wp-scripts format",
"lint:js": "wp-scripts lint-js",
"lint:md": "wp-scripts lint-md-docs",
"lint": "run-p 'lint:*'",
"type-check": "tsc --noEmit",
"build": "npm run clean:dist && run-p 'build:types' 'build:core'",
"build:types": "tsc --declaration --declarationMap --emitDeclarationOnly",
"build:dev": "npm run clean:dist && run-p 'build:types' 'build:core --node-env=development'",
"build:watch": "npm run build:dev && watch 'npm run build:dev' ./src",
"clean:dist": "rimraf dist",
"clean:release": "rimraf release",
"clean": "run-p 'clean:*'",
"release": "release-it",
"build:core": "webpack --config ./webpack/config.core.js --node-env=production"
},
"files": [
"dist",
"README.md"
],
"types": "dist/index.d.ts",
"main": "dist/index.js",
"module": "dist/index.mjs",
"peerDependencies": {
"@wordpress/ui": "*",
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
},
"devDependencies": {
"@babel/preset-react": "^7.22.5",
"@jest/globals": "^29.7.0",
"@testing-library/react": "^16.3.3",
"@types/node": "^22.6.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"babel-loader": "^9.1.3",
"lint-staged": "^15.2.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"release-it": "^17.4.0",
"rimraf": "^5.0.1",
"typescript": "^5.1.6",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@babel/preset-typescript": "^7.24.7",
"js-sha256": "^0.11.0",
"terser-webpack-plugin": "^5.3.10",
"webpack-remove-empty-scripts": "^1.0.4"
}
}
92 changes: 92 additions & 0 deletions web/packages/gravatar/src/avatar-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { sha256 } from 'js-sha256';

const AVATAR_BASE_URL = 'https://gravatar.com/avatar/';

export const MIN_SIZE = 1;
export const MAX_SIZE = 2048;

/**
* Built-in default images. Any publicly reachable image URL is also accepted.
*
* @see https://docs.gravatar.com/sdk/images/#default-image
*/
export type GravatarDefaultImage =
| 'initials'
| 'color'
| '404'
| 'mp'
| 'identicon'
| 'monsterid'
| 'wavatar'
| 'retro'
| 'robohash'
| 'blank';

export type GravatarRating = 'g' | 'pg' | 'r' | 'x';

export interface GravatarImageOptions {
/** Email address. Hashed (SHA256) client-side. Ignored when `hash` is set. */
email?: string;
/** Precomputed SHA256 hash of the trimmed, lowercased email address. */
hash?: string;
/** Image size in pixels, 1–2048 (`s`). */
size?: number;
/** Built-in default keyword or URL of a custom default image (`d`). */
default?: GravatarDefaultImage | ( string & {} );
/** Always serve the default image (`f=y`). */
forceDefault?: boolean;
/** Maximum image rating (`r`). */
rating?: GravatarRating;
/** Initials to show with `default="initials"` (`initials`). */
initials?: string;
/** Name to extract initials from with `default="initials"` (`name`). */
name?: string;
}

export function getHash( email = '' ): string {
const normalized = email.trim().toLowerCase();
return normalized ? sha256( normalized ) : '';
}

/**
* Builds a Gravatar image URL.
*
* @see https://docs.gravatar.com/sdk/images/
*/
export function getGravatarImageUrl( {
email,
hash,
size,
default: defaultImage,
forceDefault,
rating,
initials,
name,
}: GravatarImageOptions ): string {
const params = new URLSearchParams();

if ( size !== undefined ) {
const clamped = Math.min( MAX_SIZE, Math.max( MIN_SIZE, Math.round( size ) ) );
params.set( 's', String( clamped ) );
}
if ( defaultImage ) {
params.set( 'd', defaultImage );
}
if ( forceDefault ) {
params.set( 'f', 'y' );
}
if ( rating ) {
params.set( 'r', rating );
}
if ( initials ) {
params.set( 'initials', initials );
}
if ( name ) {
params.set( 'name', name );
}

const query = params.toString();
// An all-zero hash is what Gravatar serves the default image for.
const resolvedHash = hash || getHash( email ) || '0'.repeat( 64 );
return `${ AVATAR_BASE_URL }${ resolvedHash }${ query ? `?${ query }` : '' }`;
}
101 changes: 101 additions & 0 deletions web/packages/gravatar/src/gravatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { forwardRef } from 'react';
import { Avatar } from '@wordpress/ui';
import { getGravatarImageUrl, MAX_SIZE } from './avatar-url';
import type { GravatarImageOptions } from './avatar-url';

type AvatarRootProps = React.ComponentProps< typeof Avatar.Root >;
type SizeToken = NonNullable< AvatarRootProps[ 'size' ] >;

/** Pixel sizes of the `@wordpress/ui` avatar size tokens with the default design tokens. */
const TOKEN_PIXELS: Record< SizeToken, number > = {
xs: 16,
sm: 24,
md: 32,
lg: 40,
xl: 64,
};

/** Request twice the displayed size so the image stays sharp on high-density screens. */
const PIXEL_DENSITY = 2;

export type GravatarProps = Omit< AvatarRootProps, 'children' | 'size' > &
Omit< GravatarImageOptions, 'size' > & {
/**
* Displayed size: a `@wordpress/ui` avatar token, or a pixel number (1–1024).
* The image is requested from Gravatar at twice this size (`s`).
*
* @default "md"
*/
size?: SizeToken | number;
/**
* Accessible name for the avatar. Omit and the avatar is decorative (`aria-hidden`),
* for use next to text that already names the person.
*/
alt?: string;
/**
* Delay in ms before the fallback is shown while the image loads.
*/
fallbackDelay?: number;
};

/**
* A Gravatar profile image, built on the `@wordpress/ui` Avatar.
*
* ```jsx
* <Gravatar email="alex@example.com" size="lg" alt="Alex Morgan" default="initials" name="Alex Morgan" />
* ```
*/
export const Gravatar = forwardRef< HTMLSpanElement, GravatarProps >( function Gravatar(
{
email,
hash,
size = 'md',
default: defaultImage,
forceDefault,
rating,
initials,
name,
alt,
fallbackDelay,
style,
...rootProps
},
ref
) {
const pixels = typeof size === 'number' ? size : TOKEN_PIXELS[ size ];
const src = getGravatarImageUrl( {
email,
hash,
size: Math.min( MAX_SIZE, pixels * PIXEL_DENSITY ),
default: defaultImage,
forceDefault,
rating,
initials,
name,
} );

const a11yProps = alt
? { role: 'img', 'aria-label': alt }
: { 'aria-hidden': true as const };

return (
<Avatar.Root
ref={ ref }
size={ typeof size === 'number' ? undefined : size }
style={
typeof size === 'number'
? { inlineSize: pixels, blockSize: pixels, ...style }
: style
}
{ ...a11yProps }
{ ...rootProps }
>
{ name ? (
<Avatar.Initials name={ name } delay={ fallbackDelay } />
) : (
<Avatar.Fallback delay={ fallbackDelay } />
) }
<Avatar.Image src={ src } alt="" />
</Avatar.Root>
);
} );
4 changes: 4 additions & 0 deletions web/packages/gravatar/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { Gravatar } from './gravatar';
export type { GravatarProps } from './gravatar';
export { getGravatarImageUrl, getHash } from './avatar-url';
export type { GravatarImageOptions, GravatarDefaultImage, GravatarRating } from './avatar-url';
52 changes: 52 additions & 0 deletions web/packages/gravatar/tests/avatar-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from '@jest/globals';
import { getGravatarImageUrl, getHash } from '../src/avatar-url';

const HASH = '27205e5c51cb03f862138b22bcb5dc20f94a342e744ff6df1b8dc8af3c865109';

describe( 'getHash', () => {
it( 'trims and lowercases before hashing', () => {
expect( getHash( ' Foo@Example.com ' ) ).toBe( getHash( 'foo@example.com' ) );
expect( getHash( 'foo@example.com' ) ).toHaveLength( 64 );
} );

it( 'returns an empty string for blank input', () => {
expect( getHash( ' ' ) ).toBe( '' );
} );
} );

describe( 'getGravatarImageUrl', () => {
it( 'builds the base URL from a hash', () => {
expect( getGravatarImageUrl( { hash: HASH } ) ).toBe( `https://gravatar.com/avatar/${ HASH }` );
} );

it( 'maps all options to query parameters', () => {
const url = new URL(
getGravatarImageUrl( {
hash: HASH,
size: 200,
default: 'https://example.com/a.jpg',
forceDefault: true,
rating: 'pg',
initials: 'AM',
name: 'Alex Morgan',
} )
);
expect( Object.fromEntries( url.searchParams ) ).toEqual( {
s: '200',
d: 'https://example.com/a.jpg',
f: 'y',
r: 'pg',
initials: 'AM',
name: 'Alex Morgan',
} );
} );

it( 'clamps size to 1–2048', () => {
expect( getGravatarImageUrl( { hash: HASH, size: 5000 } ) ).toContain( 's=2048' );
expect( getGravatarImageUrl( { hash: HASH, size: 0 } ) ).toContain( 's=1' );
} );

it( 'falls back to an all-zero hash without email or hash', () => {
expect( getGravatarImageUrl( {} ) ).toContain( '/avatar/' + '0'.repeat( 64 ) );
} );
} );
Loading