Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add robots configuration to SEO meta tags #572

Merged
merged 3 commits into from
Feb 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/smart-students-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@shopify/hydrogen': minor
---

Added `robots` option to SEO config that allows users granular control over the robots meta tag. This can be set on both a global and per-page basis using the handle.seo property.

Example:

```ts
export handle = {
seo: {
robots: {
noIndex: false,
noFollow: false,
}
}
}
```
92 changes: 92 additions & 0 deletions packages/hydrogen/src/seo/generate-seo-tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1001,4 +1001,96 @@ describe('generateSeoTags', () => {
`);
});
});

describe('robots', () => {
it('should add a robots meta tag for noIndex and noFollow', () => {
// Given
const input = {
robots: {
noIndex: true,
noFollow: true,
},
};

// When
const output = generateSeoTags(input);

// Then
expect(output).toEqual(
expect.arrayContaining([
{
key: 'meta-robots',
props: {
content: 'noindex,nofollow',
name: 'robots',
},
tag: 'meta',
},
]),
);
});
});

it('should add a robots meta tag for index and follow', () => {
// Given
const input = {
robots: {
noIndex: false,
noFollow: false,
},
};

// When
const output = generateSeoTags(input);

// Then
expect(output).toEqual(
expect.arrayContaining([
{
key: 'meta-robots',
props: {
content: 'index,follow',
name: 'robots',
},
tag: 'meta',
},
]),
);
});

it('should add all the robots meta tags', () => {
// Given
const input = {
robots: {
noIndex: true,
noFollow: true,
noArchive: true,
noSnippet: true,
noImageIndex: true,
noTranslate: true,
maxImagePreview: 'large' as const,
maxSnippet: 100,
maxVideoPreview: 100,
unavailableAfter: new Date('2023-01-01').toLocaleDateString(),
},
};

// When
const output = generateSeoTags(input);

// Then
expect(output).toEqual(
expect.arrayContaining([
{
key: 'meta-robots',
props: {
content:
'noindex,nofollow,noarchive,noimageindex,nosnippet,notranslate,max-image-preview:large,max-snippet:100,max-video-preview:100,unavailable_after:1/1/2023',
name: 'robots',
},
tag: 'meta',
},
]),
);
});
Copy link
Contributor

@juanpprieto juanpprieto Feb 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth adding a test for a page-level robots overwriting global robots?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'd need to add a lot of utilities for more e2e style testing like this... And wouldn't be a test for this abstraction, but actually the part that touches Remix/React (aka the <Seo /> component).

You are right we should have these, but its a bigger deal and doesn't make sense in this PR.

});
166 changes: 148 additions & 18 deletions packages/hydrogen/src/seo/generate-seo-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import type {WithContext, Thing} from 'schema-dts';
import type {ComponentPropsWithoutRef} from 'react';

const ERROR_PREFIX = 'Error in SEO input: ';
// TODO: Refactor this into more reusible validators
// or use a library like zod to do this if we decide
// to use it in other places. @cartogram
// TODO: Refactor this into more reusible validators or use a library like zod to do this if we decide to use it in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should create an issue or a discussion about zod to get it going.

I think there's enough use cases for it (analytics, seo, cart) etc

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree its worth experimenting with as it is big part of astro, and even other framework's use it for this exact purpose (like Nuxt's Head abstraction).

@frehner and I talked very briefly about it. In a previous iteration of this generate-seo-tags I used it (but it was still very rushed and did more harm than good at the time).

@juanpprieto do you want to start that discussion?

// other places. @cartogram
const schema = {
title: {
validate: (value: unknown) => {
Expand Down Expand Up @@ -72,10 +71,10 @@ export interface Seo<Schema extends Thing = Thing> {
titleTemplate?: Maybe<string> | null;
/**
* The media associated with the given page (images, videos, etc). If you pass a string, it will be used as the
* `og:image` meta tag. If you pass an object or an array of objects, that will be used to generate
* `og:<type of media>` meta tags. The `url` property should be the URL of the media. The `height` and `width`
* properties are optional and should be the height and width of the media. The `altText` property is optional and
* should be a description of the media.
* `og:image` meta tag. If you pass an object or an array of objects, that will be used to generate `og:<type of
* media>` meta tags. The `url` property should be the URL of the media. The `height` and `width` properties are
* optional and should be the height and width of the media. The `altText` property is optional and should be a
* description of the media.
*
* @example
* ```js
Expand Down Expand Up @@ -204,28 +203,117 @@ export interface Seo<Schema extends Thing = Thing> {
* @see https://support.google.com/webmasters/answer/189077?hl=en
*/
alternates?: LanguageAlternate | LanguageAlternate[];
/**
* The `robots` property is used to specify the robots meta tag. This is used to tell search engines which pages
* should be indexed and which should not.
*
* @see https://developers.google.com/search/reference/robots_meta_tag
*/
robots?: RobotsOptions;
}

/**
* @see https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag
*/
interface RobotsOptions {
/**
* Set the maximum size of an image preview for this page in a search results Can be one of the following:
*
* - `none` - No image preview is to be shown.
* - `standard` - A default image preview may be shown.
* - `large` - A larger image preview, up to the width of the viewport, may be shown.
*
* If no value is specified a default image preview size is used.
*/
maxImagePreview?: 'none' | 'standard' | 'large';
/**
* A number representing the maximum of amount characters to use as a textual snippet for a search result. This value
* can also be set to one of the following special values:
*
* - 0 - No snippet is to be shown. Equivalent to nosnippet.
* - 1 - The Search engine will choose the snippet length that it believes is most effective to help users discover
* your content and direct users to your site
* - -1 - No limit on the number of characters that can be shown in the snippet.
*/
maxSnippet?: number;
/**
* The maximum number of seconds for videos on this page to show in search results. This value can also be set to one
* of the following special values:
*
* - 0 - A static image may be used with the `maxImagePreview` setting.
* - 1 - There is no limit to the size of the video preview.
*
* This applies to all forms of search results (at Google: web search, Google Images, Google Videos, Discover,
* Assistant).
*/
maxVideoPreview?: number;
/**
* Do not show a cached link in search results.
*/
noArchive?: boolean;
/**
* Do not follow the links on this page.
*
* @see https://developers.google.com/search/docs/advanced/guidelines/qualify-outbound-links
*/
noFollow?: boolean;
/**
* Do not index images on this page.
*/
noImageIndex?: boolean;
/**
* Do not show this page, media, or resource in search results.
*/
noIndex?: boolean;
/**
* Do not show a text snippet or video preview in the search results for this page.
*/
noSnippet?: boolean;
/**
* Do not offer translation of this page in search results.
*/
noTranslate?: boolean;
/**
* Do not show this page in search results after the specified date/time.
*/
unavailableAfter?: string;
}

export interface LanguageAlternate {
// Language code for the alternate page. This is used to generate the hreflang meta tag property.
/**
* Language code for the alternate page. This is used to generate the hreflang meta tag property.
*/
language: string;
// Whether or not the alternate page is the default page. This will add the `x-default` attribution to the language
// code.
/**
* Whether the alternate page is the default page. This will add the `x-default` attribution to the language code.
*/
default?: boolean;
// The url of the alternate page. This is used to generate the hreflang meta tag property.
/**
* The url of the alternate page. This is used to generate the hreflang meta tag property.
*/
url: string;
}

export type SeoMedia = {
// Used to generate og:<type of media> meta tag
/**
* Used to generate og:<type of media> meta tag
*/
type: 'image' | 'video' | 'audio';
// The url value populates both url and secure_url and is used to infer the og:<type of media>:type meta tag.
/**
* The url value populates both url and secure_url and is used to infer the og:<type of media>:type meta tag.
*/
url: Maybe<string> | undefined;
// The height in pixels of the media. This is used to generate the og:<type of media>:height meta tag.
/**
* The height in pixels of the media. This is used to generate the og:<type of media>:height meta tag.
*/
height: Maybe<number> | undefined;
// The width in pixels of the media. This is used to generate the og:<type of media>:width meta tag/
/**
* The width in pixels of the media. This is used to generate the og:<type of media>:width meta tag.
*/
width: Maybe<number> | undefined;
// The alt text for the media. This is used to generate the og:<type of media>:alt meta tag.
/**
* The alt text for the media. This is used to generate the og:<type of media>:alt meta tag.
*/
altText: Maybe<string> | undefined;
};

Expand Down Expand Up @@ -412,6 +500,49 @@ export function generateSeoTags<

break;
}

case 'robots':
// Robots
const {
maxImagePreview,
maxSnippet,
maxVideoPreview,
noArchive,
noFollow,
noImageIndex,
noIndex,
noSnippet,
noTranslate,
unavailableAfter,
} = value as RobotsOptions;

const robotsParams = [
noArchive && 'noarchive',
noImageIndex && 'noimageindex',
noSnippet && 'nosnippet',
noTranslate && `notranslate`,
maxImagePreview && `max-image-preview:${maxImagePreview}`,
maxSnippet && `max-snippet:${maxSnippet}`,
maxVideoPreview && `max-video-preview:${maxVideoPreview}`,
unavailableAfter && `unavailable_after:${unavailableAfter}`,
];
Copy link
Contributor

@juanpprieto juanpprieto Feb 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could do [].filter(Boolean) to get rid of undefined values


let robotsParam =
(noIndex ? 'noindex' : 'index') +
',' +
(noFollow ? 'nofollow' : 'follow');

for (let param of robotsParams) {
if (param) {
robotsParam += `,${param}`;
}
}

tagResults.push(
generateTag('meta', {name: 'robots', content: robotsParam}),
);

break;
}

return tagResults;
Expand Down Expand Up @@ -676,8 +807,7 @@ function validate(
} catch (error: unknown) {
const message = (error as Error).message;

// TODO: Discuss consistency of logging
// run time warnings/helpers
// TODO: Discuss consistency of logging run time warnings/helpers
console.warn(message);

return data;
Expand Down
4 changes: 4 additions & 0 deletions templates/demo-store/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ const seo: SeoHandleFunction<typeof loader> = ({data, pathname}) => ({
description: data?.layout?.shop?.description,
handle: '@shopify',
url: `https://hydrogen.shop${pathname}`,
robots: {
noIndex: false,
noFollow: false,
},
});

export const handle = {
Expand Down