mirror of
https://github.com/facebook/docusaurus.git
synced 2025-07-30 15:00:09 +02:00
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:
parent
8b877d27d4
commit
a6de0f2725
35 changed files with 1005 additions and 31 deletions
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
---
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
};
|
|
@ -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});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue