From 6007482907ada362635185f96cabd846d6841bdd Mon Sep 17 00:00:00 2001 From: Roger Date: Sun, 31 May 2020 00:01:12 -0400 Subject: [PATCH] social icons and better author links (#1375) * Change Author links to an Array This just loosely adapts the new schema to the existing template. - Changed all author content files - Updated schema - Removed some dead data imports Cherry-picked from the author page branch * Add example content to work off of * Enable viewboxes in SVGR components This is next to no data (a few bytes per SVG max) and allows SVGR-based components to be resized via CSS. * Make Twitter icon color-neutral Also update its current usage in the hamburger menu so it looks the same despite the change. * Overhaul Author Link schema Author Links are now objects with a sort of subtype defined by their `site` field. The node parser recognizes certain strings provided to the `links` field and converts them to these objects pre-filled with the correct info (right now it's Twitter, LinkedIn, and GitHub). These objects can then be queried all throughout the site, whether it be for social icons or a more detailed list. Also worth noting that since `links` is an array, its order is maintained. * Adapt FeedMeta to links and add a SocialIcon component * Improve TS fix and move SocialIcon SVGs * Remake string link processors The string processors now have their own file with an object that links hostname strings to processor functions that take a specific set of URL info gathered via regex and return an object describing Author link represented by the string. Now they're made of re-usable functional blocks, allowing for quick additions to recognize other site types. This allows us to easily re-use common patterns like "the username is the pathname with slashes stripped" while also being flexible enough to accomodate any special case, as there will always be exceptions. There's also tests for the new link parser. * Combine link parser into one file It can always be split later if additions make it cumbersome. * Embed processStringLink and move WrongTypeError closer to where it's used. * Improve LinkedIn handling Change urls to include `www.` like canonical, and make it not a function chain since we handle everything differently. * Embed and simplify trimSlashes and fix LinkedIn test --- content/authors/dmitry_petrov.md | 4 +- content/authors/elle_obrien.md | 3 +- content/authors/george_vyshnya.md | 3 +- content/authors/jorge_orpinel.md | 3 +- content/authors/marcel_rd.md | 3 +- content/authors/marija_ilic.md | 3 +- content/authors/svetlana_grinchenko.md | 3 +- gatsby-config.js | 5 +- src/components/Blog/Feed/Item/index.tsx | 9 +- src/components/Blog/FeedMeta/index.tsx | 23 ++-- .../Blog/FeedMeta/styles.module.css | 35 ++++- src/components/Blog/Post/index.tsx | 4 +- src/components/HamburgerMenu/index.tsx | 14 +- .../HamburgerMenu/styles.module.css | 1 + .../components/SocialIcon/github.svg | 0 src/components/SocialIcon/index.tsx | 43 ++++++ src/components/SocialIcon/linkedin.svg | 1 + src/components/SocialIcon/twitter.svg | 1 + .../authors/createSchemaCustomization.js | 11 +- .../authors/onCreateMarkdownContentNode.js | 6 +- src/gatsby/models/authors/parse-link.js | 89 +++++++++++++ src/gatsby/models/authors/parse-link.test.js | 126 ++++++++++++++++++ src/templates/blog-post.tsx | 9 +- static/img/community/icon-twitter.svg | 1 - 24 files changed, 358 insertions(+), 42 deletions(-) rename static/img/community/icon-github.svg => src/components/SocialIcon/github.svg (100%) create mode 100644 src/components/SocialIcon/index.tsx create mode 100644 src/components/SocialIcon/linkedin.svg create mode 100644 src/components/SocialIcon/twitter.svg create mode 100644 src/gatsby/models/authors/parse-link.js create mode 100644 src/gatsby/models/authors/parse-link.test.js delete mode 100644 static/img/community/icon-twitter.svg 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 @@ -