feat(blog): author header social icons (#10222)

Co-authored-by: OzakIOne <OzakIOne@users.noreply.github.com>
Co-authored-by: sebastien <lorber.sebastien@gmail.com>
Co-authored-by: slorber <slorber@users.noreply.github.com>
This commit is contained in:
ozaki 2024-07-12 09:59:56 +02:00 committed by GitHub
parent 8b877d27d4
commit a6de0f2725
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1005 additions and 31 deletions

View file

@ -4,15 +4,21 @@ JMarcey:
title: Technical Lead & Developer Advocate at Facebook
url: http://twitter.com/JoelMarcey
image_url: https://github.com/JoelMarcey.png
twitter: JoelMarcey
socials:
twitter: https://twitter.com/JoelMarcey
x: https://x.com/JoelMarcey
stackoverflow: https://stackoverflow.com/users/102705/Joel-Marcey
slorber:
name: Sébastien Lorber
title: Docusaurus maintainer
url: https://sebastienlorber.com
image_url: https://github.com/slorber.png
twitter: sebastienlorber
email: lorber.sebastien@gmail.com
socials:
twitter: sebastienlorber
x: sebastienlorber
stackoverflow: 82609
yangshun:
name: Yangshun Tay

View file

@ -2,6 +2,9 @@
title: Happy 1st Birthday Slash!
authors:
- name: Yangshun Tay
socials:
x: https://x.com/yangshunz
github: yangshun
- slorber
tags: [birthday,inlineTag,globalTag]
---

View file

@ -3,3 +3,7 @@ slorber:
title: Docusaurus maintainer
email: lorber.sebastien@gmail.com
url: https://sebastienlorber.com
socials:
twitter: sebastienlorber
x: https://x.com/sebastienlorber
github: slorber

View file

@ -255,6 +255,52 @@ describe('getBlogPostAuthors', () => {
]);
});
it('can normalize inline authors', () => {
expect(
getBlogPostAuthors({
frontMatter: {
authors: [
{
name: 'Seb1',
socials: {
x: 'https://x.com/sebastienlorber',
twitter: 'sebastienlorber',
github: 'slorber',
},
},
{
name: 'Seb2',
socials: {
x: 'sebastienlorber',
twitter: 'https://twitter.com/sebastienlorber',
github: 'https://github.com/slorber',
},
},
],
},
authorsMap: {},
baseUrl: '/',
}),
).toEqual([
{
name: 'Seb1',
socials: {
x: 'https://x.com/sebastienlorber',
twitter: 'https://twitter.com/sebastienlorber',
github: 'https://github.com/slorber',
},
},
{
name: 'Seb2',
socials: {
x: 'https://x.com/sebastienlorber',
twitter: 'https://twitter.com/sebastienlorber',
github: 'https://github.com/slorber',
},
},
]);
});
it('throw when using author key with no authorsMap', () => {
expect(() =>
getBlogPostAuthors({
@ -412,6 +458,29 @@ describe('getAuthorsMap', () => {
}),
).resolves.toBeUndefined();
});
describe('getAuthorsMap returns normalized', () => {
it('socials', async () => {
const authorsMap = await getAuthorsMap({
contentPaths,
authorsMapPath: 'authors.yml',
});
expect(authorsMap.slorber.socials).toMatchInlineSnapshot(`
{
"stackoverflow": "https://stackoverflow.com/users/82609",
"twitter": "https://twitter.com/sebastienlorber",
"x": "https://x.com/sebastienlorber",
}
`);
expect(authorsMap.JMarcey.socials).toMatchInlineSnapshot(`
{
"stackoverflow": "https://stackoverflow.com/users/102705/Joel-Marcey",
"twitter": "https://twitter.com/JoelMarcey",
"x": "https://x.com/JoelMarcey",
}
`);
});
});
});
describe('validateAuthorsMap', () => {
@ -529,3 +598,68 @@ describe('validateAuthorsMap', () => {
);
});
});
describe('authors socials', () => {
it('valid known author map socials', () => {
const authorsMap: AuthorsMap = {
ozaki: {
name: 'ozaki',
socials: {
twitter: 'ozakione',
github: 'ozakione',
},
},
};
expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap);
});
it('throw socials that are not strings', () => {
const authorsMap: AuthorsMap = {
ozaki: {
name: 'ozaki',
socials: {
// @ts-expect-error: for tests
twitter: 42,
},
},
};
expect(() =>
validateAuthorsMap(authorsMap),
).toThrowErrorMatchingInlineSnapshot(
`""ozaki.socials.twitter" must be a string"`,
);
});
it('throw socials that are objects', () => {
const authorsMap: AuthorsMap = {
ozaki: {
name: 'ozaki',
socials: {
// @ts-expect-error: for tests
twitter: {link: 'ozakione'},
},
},
};
expect(() =>
validateAuthorsMap(authorsMap),
).toThrowErrorMatchingInlineSnapshot(
`""ozaki.socials.twitter" must be a string"`,
);
});
it('valid unknown author map socials', () => {
const authorsMap: AuthorsMap = {
ozaki: {
name: 'ozaki',
socials: {
random: 'ozakione',
},
},
};
expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap);
});
});

View file

@ -0,0 +1,116 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {normalizeSocials} from '../authorsSocials';
import type {AuthorSocials} from '@docusaurus/plugin-content-blog';
describe('normalizeSocials', () => {
it('only username', () => {
const socials: AuthorSocials = {
twitter: 'ozakione',
linkedin: 'ozakione',
github: 'ozakione',
stackoverflow: 'ozakione',
};
expect(normalizeSocials(socials)).toMatchInlineSnapshot(`
{
"github": "https://github.com/ozakione",
"linkedin": "https://www.linkedin.com/in/ozakione/",
"stackoverflow": "https://stackoverflow.com/users/ozakione",
"twitter": "https://twitter.com/ozakione",
}
`);
});
it('only username - case insensitive', () => {
const socials: AuthorSocials = {
Twitter: 'ozakione',
linkedIn: 'ozakione',
gitHub: 'ozakione',
STACKoverflow: 'ozakione',
};
expect(normalizeSocials(socials)).toMatchInlineSnapshot(`
{
"github": "https://github.com/ozakione",
"linkedin": "https://www.linkedin.com/in/ozakione/",
"stackoverflow": "https://stackoverflow.com/users/ozakione",
"twitter": "https://twitter.com/ozakione",
}
`);
});
it('only links', () => {
const socials: AuthorSocials = {
twitter: 'https://x.com/ozakione',
linkedin: 'https://linkedin.com/ozakione',
github: 'https://github.com/ozakione',
stackoverflow: 'https://stackoverflow.com/ozakione',
};
expect(normalizeSocials(socials)).toEqual(socials);
});
it('mixed links', () => {
const socials: AuthorSocials = {
twitter: 'ozakione',
linkedin: 'ozakione',
github: 'https://github.com/ozakione',
stackoverflow: 'https://stackoverflow.com/ozakione',
};
expect(normalizeSocials(socials)).toMatchInlineSnapshot(`
{
"github": "https://github.com/ozakione",
"linkedin": "https://www.linkedin.com/in/ozakione/",
"stackoverflow": "https://stackoverflow.com/ozakione",
"twitter": "https://twitter.com/ozakione",
}
`);
});
it('one link', () => {
const socials: AuthorSocials = {
twitter: 'ozakione',
};
expect(normalizeSocials(socials)).toMatchInlineSnapshot(`
{
"twitter": "https://twitter.com/ozakione",
}
`);
});
it('rejects strings that do not look like username/userId/handle or fully-qualified URLs', () => {
const socials: AuthorSocials = {
twitter: '/ozakione/XYZ',
};
expect(() => normalizeSocials(socials)).toThrowErrorMatchingInlineSnapshot(`
"Author socials should be usernames/userIds/handles, or fully qualified HTTP(s) absolute URLs.
Social platform 'twitter' has illegal value '/ozakione/XYZ'"
`);
});
it('allow other form of urls', () => {
const socials: AuthorSocials = {
twitter: 'https://bit.ly/sebastienlorber-twitter',
};
expect(normalizeSocials(socials)).toEqual(socials);
});
it('allow unknown social platforms urls', () => {
const socials: AuthorSocials = {
twitch: 'https://www.twitch.tv/sebastienlorber',
newsletter: 'https://thisweekinreact.com',
};
expect(normalizeSocials(socials)).toEqual(socials);
});
});

View file

@ -5,8 +5,10 @@
* LICENSE file in the root directory of this source tree.
*/
import * as _ from 'lodash';
import {getDataFileData, normalizeUrl} from '@docusaurus/utils';
import {Joi, URISchema} from '@docusaurus/utils-validation';
import {AuthorSocialsSchema, normalizeSocials} from './authorsSocials';
import type {BlogContentPaths} from './types';
import type {
Author,
@ -20,12 +22,13 @@ export type AuthorsMap = {[authorKey: string]: Author};
const AuthorsMapSchema = Joi.object<AuthorsMap>()
.pattern(
Joi.string(),
Joi.object({
Joi.object<Author>({
name: Joi.string(),
url: URISchema,
imageURL: URISchema,
title: Joi.string(),
email: Joi.string(),
socials: AuthorSocialsSchema,
})
.rename('image_url', 'imageURL')
.or('name', 'imageURL')
@ -51,18 +54,32 @@ export function validateAuthorsMap(content: unknown): AuthorsMap {
return value;
}
function normalizeAuthor(author: Author): Author {
return {
...author,
socials: author.socials ? normalizeSocials(author.socials) : undefined,
};
}
function normalizeAuthorsMap(authorsMap: AuthorsMap): AuthorsMap {
return _.mapValues(authorsMap, normalizeAuthor);
}
export async function getAuthorsMap(params: {
authorsMapPath: string;
contentPaths: BlogContentPaths;
}): Promise<AuthorsMap | undefined> {
return getDataFileData(
const authorsMap = await getDataFileData(
{
filePath: params.authorsMapPath,
contentPaths: params.contentPaths,
fileType: 'authors map',
},
// TODO annoying to test: tightly coupled FS reads + validation...
validateAuthorsMap,
);
return authorsMap ? normalizeAuthorsMap(authorsMap) : undefined;
}
type AuthorsParam = {
@ -115,7 +132,7 @@ function getFrontMatterAuthorLegacy({
function normalizeFrontMatterAuthors(
frontMatterAuthors: BlogPostFrontMatterAuthors = [],
): BlogPostFrontMatterAuthor[] {
function normalizeAuthor(
function normalizeFrontMatterAuthor(
authorInput: string | Author,
): BlogPostFrontMatterAuthor {
if (typeof authorInput === 'string') {
@ -128,8 +145,8 @@ function normalizeFrontMatterAuthors(
}
return Array.isArray(frontMatterAuthors)
? frontMatterAuthors.map(normalizeAuthor)
: [normalizeAuthor(frontMatterAuthors)];
? frontMatterAuthors.map(normalizeFrontMatterAuthor)
: [normalizeFrontMatterAuthor(frontMatterAuthors)];
}
function getFrontMatterAuthors(params: AuthorsParam): Author[] {
@ -158,11 +175,11 @@ ${Object.keys(authorsMap)
}
function toAuthor(frontMatterAuthor: BlogPostFrontMatterAuthor): Author {
return {
return normalizeAuthor({
// Author def from authorsMap can be locally overridden by front matter
...getAuthorsMapAuthor(frontMatterAuthor.key),
...frontMatterAuthor,
};
});
}
return frontMatterAuthors.map(toAuthor);

View file

@ -0,0 +1,64 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {Joi} from '@docusaurus/utils-validation';
import type {
AuthorSocials,
SocialPlatformKey,
} from '@docusaurus/plugin-content-blog';
export const AuthorSocialsSchema = Joi.object<AuthorSocials>({
twitter: Joi.string(),
github: Joi.string(),
linkedin: Joi.string(),
// StackOverflow userIds like '82609' are parsed as numbers by Yaml
stackoverflow: Joi.alternatives()
.try(Joi.number(), Joi.string())
.custom((val) => String(val)),
x: Joi.string(),
}).unknown();
type PredefinedPlatformNormalizer = (value: string) => string;
const PredefinedPlatformNormalizers: Record<
SocialPlatformKey | string,
PredefinedPlatformNormalizer
> = {
x: (handle: string) => `https://x.com/${handle}`,
twitter: (handle: string) => `https://twitter.com/${handle}`,
github: (handle: string) => `https://github.com/${handle}`,
linkedin: (handle: string) => `https://www.linkedin.com/in/${handle}/`,
stackoverflow: (userId: string) =>
`https://stackoverflow.com/users/${userId}`,
};
type SocialEntry = [string, string];
function normalizeSocialEntry([platform, value]: SocialEntry): SocialEntry {
const normalizer = PredefinedPlatformNormalizers[platform.toLowerCase()];
const isAbsoluteUrl =
value.startsWith('http://') || value.startsWith('https://');
if (isAbsoluteUrl) {
return [platform, value];
} else if (value.includes('/')) {
throw new Error(
`Author socials should be usernames/userIds/handles, or fully qualified HTTP(s) absolute URLs.
Social platform '${platform}' has illegal value '${value}'`,
);
}
if (normalizer && !isAbsoluteUrl) {
const normalizedPlatform = platform.toLowerCase();
const normalizedValue = normalizer(value);
return [normalizedPlatform as SocialPlatformKey, normalizedValue];
}
return [platform, value];
}
export const normalizeSocials = (socials: AuthorSocials): AuthorSocials => {
return Object.fromEntries(Object.entries(socials).map(normalizeSocialEntry));
};

View file

@ -13,6 +13,7 @@ import {
URISchema,
validateFrontMatter,
} from '@docusaurus/utils-validation';
import {AuthorSocialsSchema} from './authorsSocials';
import type {BlogPostFrontMatter} from '@docusaurus/plugin-content-blog';
const BlogPostFrontMatterAuthorSchema = Joi.object({
@ -21,6 +22,7 @@ const BlogPostFrontMatterAuthorSchema = Joi.object({
title: Joi.string(),
url: URISchema,
imageURL: Joi.string(),
socials: AuthorSocialsSchema,
})
.or('key', 'name', 'imageURL')
.rename('image_url', 'imageURL', {alias: true});

View file

@ -43,6 +43,29 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
authorsImageUrls: (string | undefined)[];
};
/**
* Note we don't pre-define all possible platforms
* Users can add their own custom platforms if needed
*/
export type SocialPlatformKey =
| 'twitter'
| 'github'
| 'linkedin'
| 'stackoverflow'
| 'x';
/**
* Social platforms of the author.
* The record value is usually the fully qualified link of the social profile.
* For pre-defined platforms, it's possible to pass a handle instead
*/
export type AuthorSocials = Partial<Record<SocialPlatformKey, string>> & {
/**
* Unknown keys are allowed: users can pass additional social platforms
*/
[customAuthorSocialPlatform: string]: string;
};
export type Author = {
key?: string; // TODO temporary, need refactor
@ -70,10 +93,14 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
*/
email?: string;
/**
* Unknown keys are allowed, so that we can pass custom fields to authors,
* e.g., `twitter`.
* Social platforms of the author
* Usually displayed as a list of social icon links.
*/
[key: string]: unknown;
socials?: AuthorSocials;
/**
* Unknown keys are allowed, so that we can pass custom fields to authors,
*/
[customAuthorAttribute: string]: unknown;
};
/**