diff --git a/content/authors/dmitry_petrov.md b/content/authors/dmitry_petrov.md index 96b3c100db..ce7c219a33 100644 --- a/content/authors/dmitry_petrov.md +++ b/content/authors/dmitry_petrov.md @@ -1,7 +1,9 @@ --- name: Dmitry Petrov avatar: dmitry_petrov.png -link: https://twitter.com/fullstackml +links: + - https://twitter.com/fullstackml + - https://www.linkedin.com/in/dmitryleopetrov --- Creator of [http://dvc.org](http://dvc.org) — Git for ML. Ex-Data Scientist diff --git a/content/authors/elle_obrien.md b/content/authors/elle_obrien.md index 1f6e094e40..228834818a 100644 --- a/content/authors/elle_obrien.md +++ b/content/authors/elle_obrien.md @@ -1,7 +1,8 @@ --- name: Elle O'Brien avatar: elle_obrien.jpg -link: https://twitter.com/andronovhopf +links: + - https://twitter.com/andronovhopf --- Data scientist at [http://dvc.org](http://dvc.org) diff --git a/content/authors/george_vyshnya.md b/content/authors/george_vyshnya.md index 20ce679768..b1c92bd406 100644 --- a/content/authors/george_vyshnya.md +++ b/content/authors/george_vyshnya.md @@ -1,7 +1,8 @@ --- name: George Vyshnya avatar: george_vyshnya.jpeg -link: https://www.linkedin.com/in/gvyshnya +links: + - https://www.linkedin.com/in/gvyshnya --- Seasoned Data Scientist / Software Developer with blended experience in software diff --git a/content/authors/jorge_orpinel.md b/content/authors/jorge_orpinel.md index bbe25f494a..95c8754b96 100644 --- a/content/authors/jorge_orpinel.md +++ b/content/authors/jorge_orpinel.md @@ -1,7 +1,8 @@ --- name: Jorge Orpinel Pérez avatar: jorge.jpg -link: https://www.linkedin.com/in/jorgeorpinel +links: + - https://www.linkedin.com/in/jorgeorpinel --- Technical writer and developer at [dvc.org](http://dvc.org/) diff --git a/content/authors/marcel_rd.md b/content/authors/marcel_rd.md index b87d30cff4..95ae8898f5 100644 --- a/content/authors/marcel_rd.md +++ b/content/authors/marcel_rd.md @@ -1,7 +1,8 @@ --- name: Marcel Ribeiro-Dantas avatar: marcel.jpg -link: https://twitter.com/mribeirodantas +links: + - https://twitter.com/mribeirodantas --- Early Stage Researcher at [Institut Curie](https://intstitut-curie.org) with diff --git a/content/authors/marija_ilic.md b/content/authors/marija_ilic.md index 99b101c6cb..fe23e8ea70 100644 --- a/content/authors/marija_ilic.md +++ b/content/authors/marija_ilic.md @@ -1,7 +1,8 @@ --- name: Marija Ilić avatar: marija_ilic.png -link: https://www.linkedin.com/in/marija-ili%C4%87-65b8a53 +links: + - https://www.linkedin.com/in/marija-ili%C4%87-65b8a53 --- Data scientist at Njuškalo, Croatia. diff --git a/content/authors/svetlana_grinchenko.md b/content/authors/svetlana_grinchenko.md index 0d769f245d..e831a6f66a 100644 --- a/content/authors/svetlana_grinchenko.md +++ b/content/authors/svetlana_grinchenko.md @@ -1,7 +1,8 @@ --- name: Svetlana Grinchenko avatar: svetlana_grinchenko.jpeg -link: https://twitter.com/a142hr +links: + - https://twitter.com/a142hr --- Head of developer relations at [http://dvc.org](http://dvc.org) diff --git a/gatsby-config.js b/gatsby-config.js index 3740285f0a..e230944c4c 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -99,7 +99,10 @@ const plugins = [ { resolve: 'gatsby-plugin-svgr', options: { - ref: true + ref: true, + svgoConfig: { + plugins: [{ removeViewBox: false }] + } } }, 'gatsby-transformer-sharp', diff --git a/src/components/Blog/Feed/Item/index.tsx b/src/components/Blog/Feed/Item/index.tsx index 87ae298f16..64b74e3108 100644 --- a/src/components/Blog/Feed/Item/index.tsx +++ b/src/components/Blog/Feed/Item/index.tsx @@ -4,6 +4,7 @@ import { graphql } from 'gatsby' import Link from '../../../Link' import Image, { FixedObject, FluidObject } from 'gatsby-image' import cn from 'classnames' +import { ISocialIcon } from '../../../SocialIcon' import FeedMeta from '../../FeedMeta' @@ -28,6 +29,7 @@ export interface IBlogPostData { avatar: { fixed: FixedObject } + links: Array } } @@ -40,7 +42,7 @@ const Item: React.FC = ({ big, feedPost: { title, description, date, picture, author, slug, timeToRead } }) => { - const { avatar, name } = author + const { avatar, name, links } = author const bodyRef = useRef(null) const { width } = useWindowSize() const [isOverflown, setIsOverflown] = useRafState(true) @@ -84,6 +86,7 @@ const Item: React.FC = ({ name={name} avatar={avatar} date={date} + links={links} timeToRead={timeToRead} /> @@ -120,6 +123,10 @@ export const query = graphql` } author { name + links { + url + site + } avatar { fixed(width: 40, height: 40, quality: 50, cropFocus: CENTER) { ...GatsbyImageSharpFixed_withWebp diff --git a/src/components/Blog/FeedMeta/index.tsx b/src/components/Blog/FeedMeta/index.tsx index 66ec6a80e3..13b369d81d 100644 --- a/src/components/Blog/FeedMeta/index.tsx +++ b/src/components/Blog/FeedMeta/index.tsx @@ -5,6 +5,7 @@ import Link from '../../Link' import { pluralizeComments } from '../../../utils/front/i18n' import styles from './styles.module.css' +import SocialIcon, { ISocialIcon } from '../../SocialIcon' interface IBlogFeedMetaProps { avatar: { @@ -15,7 +16,7 @@ interface IBlogFeedMetaProps { date: string name: string timeToRead: string - link?: string + links: Array } const FeedMeta: React.FC = ({ @@ -25,21 +26,21 @@ const FeedMeta: React.FC = ({ date, name, timeToRead, - link + links }) => { return (
    -
  • - {link ? ( - - {name} - - ) : ( - name - )} -
  • +
  • {name}
  • + {links && ( +
  • + {links.map(({ site, url }, i) => ( + + ))} +
  • + )} +
  • {date} • {timeToRead} min read
  • diff --git a/src/components/Blog/FeedMeta/styles.module.css b/src/components/Blog/FeedMeta/styles.module.css index e78afebeff..306b6e5dfc 100644 --- a/src/components/Blog/FeedMeta/styles.module.css +++ b/src/components/Blog/FeedMeta/styles.module.css @@ -21,15 +21,20 @@ padding: 0; } -.item { +.segment { @mixin text-secondary; - position: relative; display: inline-block; - margin-right: 14px; - white-space: nowrap; line-height: 20px; color: var(--color-gray); + vertical-align: middle; +} + +.item { + composes: segment; + white-space: nowrap; + position: relative; + margin-right: 14px; &::before { content: '• '; @@ -38,6 +43,28 @@ } } +.linkIcons { + composes: segment; + padding: 0 10px 0 0.1rem; + display: inline-block; + + a { + display: inline-block; + box-sizing: border-box; + vertical-align: middle; + color: inherit; + width: 26px; + height: 26px; + padding: 0.2rem; + } + + img, + svg { + width: 100%; + height: 100%; + } +} + .link { @mixin link; } diff --git a/src/components/Blog/Post/index.tsx b/src/components/Blog/Post/index.tsx index f190efad86..c19d7236f7 100644 --- a/src/components/Blog/Post/index.tsx +++ b/src/components/Blog/Post/index.tsx @@ -31,7 +31,7 @@ const Post: React.FC = ({ descriptionLong, commentsUrl, tags, - author: { name, avatar, link }, + author: { name, avatar, links }, slug }) => { const wrapperRef = useRef(null) @@ -79,7 +79,7 @@ const Post: React.FC = ({ avatar={avatar} date={date} timeToRead={timeToRead} - link={link} + links={links} />
diff --git a/src/components/HamburgerMenu/index.tsx b/src/components/HamburgerMenu/index.tsx index 7c95167c65..98bb4ef6f2 100644 --- a/src/components/HamburgerMenu/index.tsx +++ b/src/components/HamburgerMenu/index.tsx @@ -7,6 +7,8 @@ import Link from '../Link' import { logEvent } from '../../utils/front/ga' import { getFirstPage } from '../../utils/shared/sidebar' import { ReactComponent as LogoSVG } from '../../../static/img/logo-white.svg' +import { ReactComponent as TwitterIcon } from '../SocialIcon/twitter.svg' +import { ReactComponent as GithubIcon } from '../SocialIcon/github.svg' import styles from './styles.module.css' @@ -180,11 +182,7 @@ const HamburgerMenu: React.FC = () => { onClick={itemClick('github')} target="_blank" > - + GitHub @@ -210,11 +208,7 @@ const HamburgerMenu: React.FC = () => { onClick={itemClick('twitter')} target="_blank" > - + Twitter diff --git a/src/components/HamburgerMenu/styles.module.css b/src/components/HamburgerMenu/styles.module.css index 2ff62c778d..5f1ed220b3 100644 --- a/src/components/HamburgerMenu/styles.module.css +++ b/src/components/HamburgerMenu/styles.module.css @@ -106,6 +106,7 @@ .subSectionLinkImage { display: block; margin: 0 auto 5px; + color: #fff; } .subSectionLinkTitle { diff --git a/static/img/community/icon-github.svg b/src/components/SocialIcon/github.svg similarity index 100% rename from static/img/community/icon-github.svg rename to src/components/SocialIcon/github.svg diff --git a/src/components/SocialIcon/index.tsx b/src/components/SocialIcon/index.tsx new file mode 100644 index 0000000000..860f90e8fc --- /dev/null +++ b/src/components/SocialIcon/index.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import Link from '../Link' +import { ReactComponent as TwitterIcon } from './twitter.svg' +import { ReactComponent as GithubIcon } from './github.svg' +import { ReactComponent as LinkedInIcon } from './linkedin.svg' + +const icons: { [site: string]: JSX.Element } = { + linkedin: , + github: , + twitter: +} + +export interface ISocialIcon { + url: string + site?: string +} + +export interface ISocialIconProps extends ISocialIcon { + className?: string +} + +/* + Returns a link containing an icon corresponding to the provided site + + Given the situation where either the given link has no site or we don't have + an icon for it, we return null such that nothing is rendered in map + functions. +*/ +const SocialIcon: React.FC = ({ + site, + url, + className +}): JSX.Element | null => { + /* eslint-disable-next-line */ + const icon: JSX.Element = icons[site!] + return icon ? ( + + {icon} + + ) : null +} + +export default SocialIcon diff --git a/src/components/SocialIcon/linkedin.svg b/src/components/SocialIcon/linkedin.svg new file mode 100644 index 0000000000..5d613c3bbf --- /dev/null +++ b/src/components/SocialIcon/linkedin.svg @@ -0,0 +1 @@ + diff --git a/src/components/SocialIcon/twitter.svg b/src/components/SocialIcon/twitter.svg new file mode 100644 index 0000000000..6a12e86c45 --- /dev/null +++ b/src/components/SocialIcon/twitter.svg @@ -0,0 +1 @@ + diff --git a/src/gatsby/models/authors/createSchemaCustomization.js b/src/gatsby/models/authors/createSchemaCustomization.js index ea7c0047f9..24e84d8232 100644 --- a/src/gatsby/models/authors/createSchemaCustomization.js +++ b/src/gatsby/models/authors/createSchemaCustomization.js @@ -7,6 +7,14 @@ async function createAuthorSchemaCustomization(api) { schema: { buildObjectType } } = api const typeDefs = [ + buildObjectType({ + name: 'AuthorLink', + fields: { + url: 'String!', + site: 'String', + username: 'String' + } + }), buildObjectType({ name: 'AuthorPosts', fields: { @@ -19,7 +27,8 @@ async function createAuthorSchemaCustomization(api) { interfaces: ['Node'], fields: { ...markdownParentFields, - link: 'String', + links: '[AuthorLink]', + slug: 'String', avatar: { type: 'ImageSharp', resolve: resolveAuthorAvatar diff --git a/src/gatsby/models/authors/onCreateMarkdownContentNode.js b/src/gatsby/models/authors/onCreateMarkdownContentNode.js index 32ddb8d598..cd50560bbf 100644 --- a/src/gatsby/models/authors/onCreateMarkdownContentNode.js +++ b/src/gatsby/models/authors/onCreateMarkdownContentNode.js @@ -1,8 +1,10 @@ +const parseLink = require('./parse-link.js') + async function createMarkdownAuthorNode(api, { parentNode, createChildNode }) { if (parentNode.relativeDirectory.split('/')[0] !== 'authors') return const { node, createNodeId, createContentDigest } = api const { frontmatter, rawMarkdownBody } = node - const { path, name, avatar, link } = frontmatter + const { path, name, avatar, links } = frontmatter const { relativePath } = parentNode const fieldData = { @@ -10,7 +12,7 @@ async function createMarkdownAuthorNode(api, { parentNode, createChildNode }) { rawMarkdownBody, path, name, - link, + links: links.map(parseLink), avatar } diff --git a/src/gatsby/models/authors/parse-link.js b/src/gatsby/models/authors/parse-link.js new file mode 100644 index 0000000000..201f5d57a2 --- /dev/null +++ b/src/gatsby/models/authors/parse-link.js @@ -0,0 +1,89 @@ +/* + Processor builder: makes a function that turns a string into processed Link metadata + + Takes "Mod functions" as parameters, returns a function that starts with {} and pipes + that through each function, then returns the result. + + signature: ({ scheme: string, host: string, pathname: string}, input) + example: ^ https:// ^ mysite.com ^ /rest/of/the/url ^ an object + + The second parameter will be the current state of the input, and usually will + have to be spread into your return result. + */ +function processor(...mods) { + return function process(groups) { + return mods.reduce( + (currentResult, _, i) => mods[i](groups, currentResult), + {} + ) + } +} + +// Processor helpers + +const asSite = site => (groups, input) => ({ + ...input, + site +}) + +const pathnameAsUsername = ({ pathname }, input) => ({ + ...input, + username: /^\/?(.*)$/.exec(pathname)[1] +}) + +const urlHTTPS = ({ host, pathname }, input) => ({ + ...input, + url: 'https://' + host + pathname +}) + +const urlDefault = ({ scheme, host, pathname }, input) => ({ + ...input, + url: (scheme || 'https://') + host + pathname +}) + +// Building processors for recognized sites + +const processors = { + 'twitter.com': processor(asSite('twitter'), urlHTTPS, pathnameAsUsername), + 'github.com': processor(asSite('github'), urlHTTPS, pathnameAsUsername), + // Handle LinkedIn as a special case + 'linkedin.com': ({ pathname }) => ({ + site: 'linkedin', + username: /^\/in\/(.*)/.exec(pathname)[1], + // LinkedIn's canonical includes www. + url: 'https://www.linkedin.com' + pathname + }) +} + +const defaultProcessor = processor(asSite(null), urlDefault) + +function WrongTypeError(type) { + return new Error(`A ${type} cannot be used as input for parseLink!`) +} + +function parseLink(input) { + switch (typeof input) { + // Handle shorthand string links + case 'string': + const { + groups + } = /^(?.*?\/\/)?(?:www\.)?(?[^\/]*)(?.*?)\/?$/.exec( + input + ) + + // Extract groups into variables and assign defaults to non-matches + return (processors[groups.host] || defaultProcessor)(groups) + + // Pass object links through + case 'object': + // typeof null is object, so handle that + if (input === null) throw WrongTypeError('null') + return input + + // Throw on anything else + default: + throw WrongTypeError(typeof input) + } +} + +module.exports = parseLink diff --git a/src/gatsby/models/authors/parse-link.test.js b/src/gatsby/models/authors/parse-link.test.js new file mode 100644 index 0000000000..1a18cc392b --- /dev/null +++ b/src/gatsby/models/authors/parse-link.test.js @@ -0,0 +1,126 @@ +const parseLink = require('./parse-link.js') + +function expectPair(input, expected) { + expect(parseLink(input)).toEqual(expected) +} + +function expectAll(expected, inputs) { + inputs.forEach(input => expectPair(input, expected)) +} + +const withTrailingSlashesAlso = links => [ + ...links, + ...links.map(link => link + '/') +] + +describe('URL formatting', () => { + test('defaults the scheme to HTTPS, removes www, and strips trailing slash', () => { + expectPair('www.unknownsite.com/', { + site: null, + url: 'https://unknownsite.com' + }) + }) +}) + +describe('Different sites', () => { + test('Twitter', () => { + const exampleResult = { + site: 'twitter', + username: 'testman123', + url: 'https://twitter.com/testman123' + } + expectAll( + exampleResult, + withTrailingSlashesAlso([ + 'https://www.twitter.com/testman123', + 'http://www.twitter.com/testman123', + 'www.twitter.com/testman123', + 'twitter.com/testman123' + ]) + ) + }) + + test('LinkedIn', () => { + expectAll( + { + site: 'linkedin', + username: 'testman123', + url: 'https://www.linkedin.com/in/testman123' + }, + withTrailingSlashesAlso([ + 'https://www.linkedin.com/in/testman123', + 'http://www.linkedin.com/in/testman123', + 'www.linkedin.com/in/testman123', + 'linkedin.com/in/testman123' + ]) + ) + }) + + test('GitHub', () => { + expectAll( + { + site: 'github', + username: 'testman123', + url: 'https://github.com/testman123' + }, + withTrailingSlashesAlso([ + 'https://www.github.com/testman123', + 'http://www.github.com/testman123', + 'www.github.com/testman123', + 'github.com/testman123' + ]) + ) + }) + + test('Unrecognized site', () => { + expectAll( + { + site: null, + url: 'https://mysweethomepage.com' + }, + withTrailingSlashesAlso([ + 'https://www.mysweethomepage.com', + 'www.mysweethomepage.com', + 'mysweethomepage.com' + ]) + ) + }) + + test('Site with a non-www subdomain', () => { + expectAll( + { + site: null, + url: 'https://subdomain.mysweethomepage.com' + }, + withTrailingSlashesAlso([ + 'https://subdomain.mysweethomepage.com', + 'subdomain.mysweethomepage.com' + ]) + ) + }) +}) + +describe('Passing objects', () => { + test('returns the same object, with no post-processing', () => { + const testValue = { url: 'twitter.com/testman123/' } + expectPair(testValue, testValue) + }) +}) + +describe('Throws', () => { + test('when given a null', () => { + expect(() => { + parseLink(null) + }).toThrow(Error) + }) + test('when given an undefined', () => { + expect(() => { + parseLink(undefined) + }).toThrow(Error) + }) + test('when given a Number', () => { + expect(() => { + parseLink(1) + }).toThrow(Error) + }) +}) diff --git a/src/templates/blog-post.tsx b/src/templates/blog-post.tsx index 43c6ece01c..85db91782c 100644 --- a/src/templates/blog-post.tsx +++ b/src/templates/blog-post.tsx @@ -6,6 +6,8 @@ import React from 'react' import SEO from '../components/SEO' import Post from '../components/Blog/Post' +import { ISocialIcon } from '../components/SocialIcon' + interface IFluidObject extends FluidObject { presentationWidth: number presentationHeight: number @@ -42,10 +44,10 @@ export interface IBlogPostData { pictureComment?: string author: { name: string - link?: string avatar: { fixed: FixedObject } + links: Array } } @@ -88,7 +90,10 @@ export const pageQuery = graphql` commentsUrl author { name - link + links { + url + site + } avatar { fixed(width: 40, height: 40, quality: 50, cropFocus: CENTER) { ...GatsbyImageSharpFixed_withWebp diff --git a/static/img/community/icon-twitter.svg b/static/img/community/icon-twitter.svg deleted file mode 100644 index 450f448afd..0000000000 --- a/static/img/community/icon-twitter.svg +++ /dev/null @@ -1 +0,0 @@ -