Render more Helmet stuff

This commit is contained in:
Vesa Luusua 2017-09-28 11:26:37 +03:00
parent 02ef54c898
commit 82f73a8016
26 changed files with 390 additions and 67 deletions

View file

@ -1,10 +1,13 @@
<!doctype html>
<html>
<html data-htmlattr="htmlAttributes">
<head>
<meta charset="utf-8">
<!--!title-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--!meta-->
<link rel="shortcut icon" href="%PUBLIC_URL%/static/favicon.ico">
<!--!link-->
<style>
/**
@ -68,5 +71,6 @@
<script src="https://js.stripe.com/v2/"></script>
<script src="https://js.stripe.com/v3/"></script>
<!--!preloadedStateScript-->
<!--!script-->
</body>
</html>

View file

@ -13,30 +13,60 @@ const buildPath = path.resolve(__dirname, '..', 'build');
const indexHtml = fs.readFileSync(path.join(buildPath, 'index.html'), 'utf-8');
const reNoMatch = /($^)/;
const template = _.template(indexHtml, {
// Interpolate variables in the HTML template with the following
// syntax: <!--!variableName-->
// Not all the Helmet provided data is tags to be added inside <head> or <body>
// <html> tag's attributes need separate interpolation functionality
const templateWithHtmlAttributes = _.template(indexHtml, {
// Interpolate htmlAttributes (Helmet data) in the HTML template with the following
// syntax: data-htmlattr="variableName"
//
// This syntax is very intentional: it works as a HTML comment and
// doesn't render anything visual in the dev mode, and in the
// production mode, HtmlWebpackPlugin strips out comments using
// HTMLMinifier except those that aren't explicitly marked as custom
// comments. By default, custom comments are those that begin with a
// ! character.
// This syntax is very intentional: it works as a data attribute and
// doesn't render attributes that have special meaning in HTML renderig
// (except containing some data).
//
// Note that the variables are _not_ escaped since we only inject
// HTML content.
//
// See:
// - https://github.com/ampedandwired/html-webpack-plugin
// - https://github.com/kangax/html-minifier
// - Plugin options in the production Webpack configuration file
interpolate: /<!--!([\s\S]+?)-->/g,
// This special attribute should be added to <html> tag
// It may contain attributes like lang, itemscope, and itemtype
interpolate: /data-htmlattr=\"([\s\S]+?)\"/g,
// Disable evaluated and escaped variables in the template
evaluate: reNoMatch,
escape: reNoMatch,
});
// Template tags inside given template string (templatedWithHtmlAttributes),
// which cantains <html> attributes already.
const templateTags = templatedWithHtmlAttributes =>
_.template(templatedWithHtmlAttributes, {
// Interpolate variables in the HTML template with the following
// syntax: <!--!variableName-->
//
// This syntax is very intentional: it works as a HTML comment and
// doesn't render anything visual in the dev mode, and in the
// production mode, HtmlWebpackPlugin strips out comments using
// HTMLMinifier except those that aren't explicitly marked as custom
// comments. By default, custom comments are those that begin with a
// ! character.
//
// Note that the variables are _not_ escaped since we only inject
// HTML content.
//
// See:
// - https://github.com/ampedandwired/html-webpack-plugin
// - https://github.com/kangax/html-minifier
// - Plugin options in the production Webpack configuration file
interpolate: /<!--!([\s\S]+?)-->/g,
// Disable evaluated and escaped variables in the template
evaluate: reNoMatch,
escape: reNoMatch,
});
// Interpolate htmlAttributes and other helmet data into the template
const template = params => {
const htmlAttributes = params.htmlAttributes;
const tags = _.omit(params, ['htmlAttributes']);
const templatedWithHtmlAttributes = templateWithHtmlAttributes({ htmlAttributes });
return templateTags(templatedWithHtmlAttributes)(tags);
};
exports.render = function(requestUrl, context, preloadedState) {
const { head, body } = renderApp(requestUrl, context, preloadedState);
@ -54,5 +84,13 @@ exports.render = function(requestUrl, context, preloadedState) {
<script>window.__PRELOADED_STATE__ = ${JSON.stringify(serializedState)};</script>
`;
return template({ title: head.title.toString(), preloadedStateScript, body });
return template({
htmlAttributes: head.htmlAttributes.toString(),
title: head.title.toString(),
link: head.link.toString(),
meta: head.meta.toString(),
script: head.script.toString(),
preloadedStateScript,
body,
});
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -1,9 +1,12 @@
import React, { Component, PropTypes } from 'react';
import Helmet from 'react-helmet';
import { withRouter } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import classNames from 'classnames';
import { canonicalURL, metaTagProps } from '../../util/seo';
import facebookImage from '../../assets/saunatimeFacebook-1200x630.jpg';
import twitterImage from '../../assets/saunatimeTwitter-600x314.jpg';
import css from './PageLayout.css';
const scrollToTop = () => {
@ -11,7 +14,7 @@ const scrollToTop = () => {
window.scrollTo(0, 0);
};
class PageLayout extends Component {
class PageLayoutComponent extends Component {
componentDidMount() {
this.historyUnlisten = this.props.history.listen(() => scrollToTop());
}
@ -26,11 +29,22 @@ class PageLayout extends Component {
const {
className,
rootClassName,
title,
children,
authInfoError,
children,
history,
intl,
logoutError,
scrollingDisabled,
contentType,
description,
facebookImages,
published,
schema,
tags,
title,
twitterHandle,
twitterImages,
updated,
} = this.props;
// TODO: use FlashMessages for auth errors
@ -48,10 +62,61 @@ class PageLayout extends Component {
[css.scrollingDisabled]: scrollingDisabled,
});
const { pathname, search = '' } = history.location;
const pathWithSearch = `${pathname}${search}`;
const schemaTitle = intl.formatMessage({ id: 'PageLayout.schemaTitle' });
const schemaDescription = intl.formatMessage({ id: 'PageLayout.schemaDescription' });
const metaTitle = title || schemaTitle;
const metaDescription = description || schemaDescription;
const facebookImgs = facebookImages || [
{ name: 'facebook', url: facebookImage, width: 1200, height: 630 },
];
const twitterImgs = twitterImages || [
{ name: 'twitter', url: twitterImage, width: 600, height: 314 },
];
const metaToHead = metaTagProps({
contentType,
description: metaDescription,
facebookImages: facebookImgs,
twitterImages: twitterImgs,
published,
tags,
title: metaTitle,
twitterHandle,
updated,
url: canonicalURL(pathWithSearch),
});
// eslint-disable-next-line react/no-array-index-key
const metaTags = metaToHead.map((metaProps, i) => <meta key={i} {...metaProps} />);
// Schema for search engines (helps them to understand what this page is about)
// http://schema.org
// We are using JSON-LD format
const schemaJSONString = schema ||
JSON.stringify({
'@context': 'http://schema.org',
'@type': 'WebPage',
description: schemaDescription,
name: schemaTitle,
});
return (
<div className={classes}>
<Helmet>
<Helmet
htmlAttributes={{
lang: intl.locale,
}}
>
<title>{title}</title>
<link rel="canonical" href={canonicalURL(pathWithSearch)} />
{metaTags}
<script type="application/ld+json">
{schemaJSONString}
</script>
</Helmet>
{authInfoError
? <div style={{ color: 'red' }}>
@ -71,30 +136,68 @@ class PageLayout extends Component {
}
}
const { any, bool, func, instanceOf, shape, string } = PropTypes;
const { any, arrayOf, bool, func, instanceOf, number, shape, string } = PropTypes;
PageLayout.defaultProps = {
PageLayoutComponent.defaultProps = {
className: null,
rootClassName: null,
children: null,
authInfoError: null,
logoutError: null,
scrollingDisabled: false,
contentType: 'website',
description: null,
facebookImages: null,
twitterImages: null,
published: null,
schema: null,
tags: null,
twitterHandle: null,
updated: null,
};
PageLayout.propTypes = {
PageLayoutComponent.propTypes = {
className: string,
rootClassName: string,
title: string.isRequired,
children: any,
authInfoError: instanceOf(Error),
logoutError: instanceOf(Error),
scrollingDisabled: bool,
// SEO related props
contentType: string, // og:type
description: string, // page description
facebookImages: arrayOf(
shape({
width: number.isRequired,
height: number.isRequired,
url: string.isRequired,
})
),
twitterImages: arrayOf(
shape({
width: number.isRequired,
height: number.isRequired,
url: string.isRequired,
})
),
published: string, // article:published_time
schema: string, // http://schema.org
tags: string, // article:tag
title: string.isRequired, // page title
twitterHandle: string, // twitter handle
updated: string, // article:modified_time
// from withRouter
history: shape({
listen: func.isRequired,
}).isRequired,
// from injectIntl
intl: intlShape.isRequired,
};
export default withRouter(PageLayout);
const PageLayout = injectIntl(withRouter(PageLayoutComponent));
PageLayout.displayName = 'PageLayout';
export default PageLayout;

View file

@ -181,6 +181,18 @@ const stripeSupportedCountries = [
},
];
// Canonical root url is needed in social media sharing and SEO optimization purposes.
const canonicalRootURL = process.env.REACT_APP_CANONICAL_ROOT_URL || 'https://localhost:3000';
// Site title is needed in meta tags (bots and social media sharing reads those)
const siteTitle = 'Saunatime';
// Twitter handle is needed in meta tags (twitter:site)
const siteTwitterHandle = '@sharetribe';
// Facebook counts shares with app or page associated by this id
const facebookAppId = null;
// NOTE: only expose configuration that should be visible in the
// client side, don't add any server secrets in this file.
const config = {
@ -190,6 +202,10 @@ const config = {
currency,
currencyConfig,
stripe: { publishableKey: stripePublishableKey, supportedCountries: stripeSupportedCountries },
canonicalRootURL,
siteTitle,
siteTwitterHandle,
facebookAppId,
};
export default config;

View file

@ -1,5 +1,5 @@
exports[`AuthenticationPageComponent matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
scrollingDisabled={false}
@ -78,5 +78,5 @@ exports[`AuthenticationPageComponent matches snapshot 1`] = `
onSubmit={[Function]} />
</div>
</div>
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`CheckoutPage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
title="CheckoutPage.title">
@ -156,5 +156,5 @@ exports[`CheckoutPage matches snapshot 1`] = `
</h3>
</div>
</div>
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`ContactDetailsPage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
title="Contact details">
@ -81,5 +81,5 @@ exports[`ContactDetailsPage matches snapshot 1`] = `
</div>
</ContentWrapper>
</LayoutSideNavigation>
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`EditListingPageComponent matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
scrollingDisabled={false}
@ -65,5 +65,5 @@ exports[`EditListingPageComponent matches snapshot 1`] = `
"type": "new",
}
} />
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`InboxPage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
scrollingDisabled={false}
@ -253,7 +253,7 @@ exports[`InboxPage matches snapshot 1`] = `
</ul>
</div>
</div>
</withRouter(PageLayout)>
</PageLayout>
`;
exports[`InboxPage matches snapshot 2`] = `
@ -309,7 +309,7 @@ exports[`InboxPage matches snapshot 2`] = `
`;
exports[`InboxPage matches snapshot 3`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
scrollingDisabled={false}
@ -563,7 +563,7 @@ exports[`InboxPage matches snapshot 3`] = `
</ul>
</div>
</div>
</withRouter(PageLayout)>
</PageLayout>
`;
exports[`InboxPage matches snapshot 4`] = `

View file

@ -1,5 +1,5 @@
exports[`LandingPage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
scrollingDisabled={false}
@ -42,5 +42,5 @@ exports[`LandingPage matches snapshot 1`] = `
}
rootClassName={null} />
</div>
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`ListingPage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
scrollingDisabled={false}
@ -269,5 +269,5 @@ exports[`ListingPage matches snapshot 1`] = `
</div>
</div>
</div>
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`ContactDetailsPage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
scrollingDisabled={false}
@ -35,5 +35,5 @@ exports[`ContactDetailsPage matches snapshot 1`] = `
<div>
<div />
</div>
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`NotFoundPageComponent matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
title="NotFoundPage.title">
@ -32,5 +32,5 @@ exports[`NotFoundPageComponent matches snapshot 1`] = `
values={Object {}} />
</h1>
</div>
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`OrderPage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
scrollingDisabled={false}
@ -151,5 +151,5 @@ exports[`OrderPage matches snapshot 1`] = `
"type": "transaction",
}
} />
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`PasswordChangePage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
title="Contact details">
@ -81,5 +81,5 @@ exports[`PasswordChangePage matches snapshot 1`] = `
</div>
</ContentWrapper>
</LayoutSideNavigation>
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`ContactDetailsPage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
title="PasswordRecoveryPage.title">
@ -51,5 +51,5 @@ exports[`ContactDetailsPage matches snapshot 1`] = `
recoveryError={null} />
</div>
</div>
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`PayoutPreferencesPage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
title="Payout preferences">
@ -25,5 +25,5 @@ exports[`PayoutPreferencesPage matches snapshot 1`] = `
onResendVerificationEmail={[Function]}
sendVerificationEmailError={null}
sendVerificationEmailInProgress={false} />
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`ProfilePage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
title="Profile page with display name: most-awesome-shop">
@ -25,5 +25,5 @@ exports[`ProfilePage matches snapshot 1`] = `
onResendVerificationEmail={[Function]}
sendVerificationEmailError={null}
sendVerificationEmailInProgress={false} />
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`ContactDetailsPage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
scrollingDisabled={false}
@ -38,5 +38,5 @@ exports[`ContactDetailsPage matches snapshot 1`] = `
values={Object {}} />
</h1>
</div>
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`SalePage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
scrollingDisabled={false}
@ -159,5 +159,5 @@ exports[`SalePage matches snapshot 1`] = `
}
} />
</div>
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`SearchPageComponent matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
scrollingDisabled={false}
@ -92,5 +92,5 @@ exports[`SearchPageComponent matches snapshot 1`] = `
</div>
</withViewport(ModalInMobileComponent)>
</div>
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -1,5 +1,5 @@
exports[`SecurityPage matches snapshot 1`] = `
<withRouter(PageLayout)
<PageLayout
authInfoError={null}
logoutError={null}
title="Security">
@ -25,5 +25,5 @@ exports[`SecurityPage matches snapshot 1`] = `
onResendVerificationEmail={[Function]}
sendVerificationEmailError={null}
sendVerificationEmailInProgress={false} />
</withRouter(PageLayout)>
</PageLayout>
`;

View file

@ -241,6 +241,8 @@
"OrderPage.title": "Order details: {listingTitle}",
"PageLayout.authInfoFailed": "Could not get authentication information.",
"PageLayout.logoutFailed": "Logout failed. Please try again.",
"PageLayout.schemaDescription": "You can book a sauna from Saunatime or get some income by sharing your own sauna",
"PageLayout.schemaTitle": "Book saunas everywhere",
"PaginationLinks.next": "Next page",
"PaginationLinks.previous": "Previous page",
"PaginationLinks.toPage": "Go to page {page}",

160
src/util/seo.js Normal file
View file

@ -0,0 +1,160 @@
import config from '../config';
export const canonicalURL = path => `${config.canonicalRootURL}${path}`;
/**
* These will be used with Helmet <meta {...openGraphMetaProps} />
*/
export const openGraphMetaProps = data => {
const {
canonicalRootURL,
contentType,
description,
facebookAppId,
facebookImages,
published,
siteTitle,
tags,
title,
updated,
url,
} = data;
if (
!(title &&
description &&
contentType &&
url &&
facebookImages &&
facebookImages.length > 0 &&
canonicalRootURL)
) {
/* eslint-disable no-console */
if (console && console.warning) {
console.warning(
`Can't create Openg Graph meta tags:
title, description, contentType, url, facebookImages, and canonicalRootURL are needed.`
);
}
/* eslint-enable no-console */
return [];
}
const openGraphMeta = [
{ property: 'og:description', content: description },
{ property: 'og:title', content: title },
{ property: 'og:type', content: contentType },
{ property: 'og:url', content: url },
];
facebookImages.forEach(i => {
openGraphMeta.push({
property: 'og:image',
content: `${canonicalRootURL}${i.url}`,
});
if (i.width && i.height) {
openGraphMeta.push({ property: 'og:image:width', content: i.width });
openGraphMeta.push({ property: 'og:image:height', content: i.height });
}
});
if (siteTitle) {
openGraphMeta.push({ property: 'og:site_name', content: siteTitle });
}
if (facebookAppId) {
openGraphMeta.push({ property: 'fb:app_id', content: facebookAppId });
}
if (published) {
openGraphMeta.push({ property: 'article:published_time', content: published });
}
if (updated) {
openGraphMeta.push({ property: 'article:modified_time', content: updated });
}
if (tags) {
openGraphMeta.push({ property: 'article:tag', content: tags });
}
return openGraphMeta;
};
/**
* These will be used with Helmet <meta {...twitterMetaProps} />
*/
export const twitterMetaProps = data => {
const {
canonicalRootURL,
description,
siteTwitterHandle,
title,
twitterHandle,
twitterImages,
} = data;
if (!(title && description && siteTwitterHandle)) {
/* eslint-disable no-console */
if (console && console.warning) {
console.warning(
`Can't create twitter card meta tags:
title, description, and siteTwitterHandle are needed.`
);
}
/* eslint-enable no-console */
return [];
}
const twitterMeta = [
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: title },
{ name: 'twitter:description', content: description },
{ name: 'twitter:site', content: siteTwitterHandle },
];
if (canonicalRootURL && twitterImages && twitterImages.length > 0) {
twitterImages.forEach(i => {
twitterMeta.push({
name: 'twitter:image:src',
content: `${canonicalRootURL}${i.url}`,
});
});
}
if (twitterHandle) {
// TODO: this needs API support for listings
twitterMeta.push({ name: 'twitter:creator', content: twitterHandle });
}
return twitterMeta;
};
/**
* These will be used with Helmet <meta {...metaTagProps} />
* Creates data for Open Graph and Twitter meta tags.
*/
export const metaTagProps = tagData => {
const {
canonicalRootURL,
facebookAppId,
siteTitle,
siteTwitterHandle,
} = config;
const openGraphMeta = openGraphMetaProps({
...tagData,
canonicalRootURL,
facebookAppId,
siteTitle,
});
const twitterMeta = twitterMetaProps({
...tagData,
canonicalRootURL,
siteTwitterHandle,
});
return [...openGraphMeta, ...twitterMeta];
};