Skip to content
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
39 changes: 37 additions & 2 deletions packages/webapp/__tests__/GatedPagesSeo.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,52 @@
import type { NextSeoProps } from 'next-seo/lib/types';
import { getSquadInvitation } from '@dailydotdev/shared/src/graphql/squads';
import FollowingFeed from '../pages/following';
import SquadAnalytics from '../pages/squads/[handle]/analytics';
import ModerateSquad from '../pages/squads/moderate';
import { getStaticProps as getSquadInviteStaticProps } from '../pages/squads/[handle]/[token]';

jest.mock('@dailydotdev/shared/src/graphql/squads', () => {
const actual = jest.requireActual('@dailydotdev/shared/src/graphql/squads');

return {
...actual,
getSquadInvitation: jest.fn(),
};
});

type WithLayoutProps = {
layoutProps?: { seo?: NextSeoProps };
};

const layoutSeo = (page: unknown): NextSeoProps | undefined =>
(page as WithLayoutProps).layoutProps?.seo;

// Regression lock: auth-gated pages must stay noindex,nofollow so private,
// crawler-inaccessible surfaces are never advertised as indexable.
describe('gated page seo', () => {
it('following feed is noindex and nofollow', () => {
const { seo } = (FollowingFeed as WithLayoutProps).layoutProps ?? {};
it.each([
['following feed', FollowingFeed],
['squad analytics', SquadAnalytics],
['squad moderation', ModerateSquad],
])('%s is noindex and nofollow', (_, page) => {
const seo = layoutSeo(page);

expect(seo?.noindex).toBe(true);
expect(seo?.nofollow).toBe(true);
});

it.each([
['a valid invite', { user: { name: 'Ido' }, source: { name: 'My Squad' } }],
['an invalid invite', {}],
])('squad invite token page is noindex for %s', async (_, invitation) => {
(getSquadInvitation as jest.Mock).mockResolvedValue(invitation);

const result = await getSquadInviteStaticProps({
params: { handle: 'my-squad', token: 'token' },
} as never);

expect(result).toMatchObject({
props: { seo: { noindex: true, nofollow: true } },
});
});
});
35 changes: 34 additions & 1 deletion packages/webapp/__tests__/PostStaticProps.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { gqlClient } from '@dailydotdev/shared/src/graphql/common';
import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common';
import type { Post } from '@dailydotdev/shared/src/graphql/posts';
import { PostType } from '@dailydotdev/shared/src/graphql/posts';
import { getStaticProps, shouldNoindexPost } from '../pages/posts/[id]/index';
Expand Down Expand Up @@ -109,6 +109,39 @@ describe('post static props seo', () => {
});
});

it.each([
['the post is private', { private: true }],
['the source is not public', { source: { public: false } }],
])('should noindex when %s', (_, overrides) => {
expect(
shouldNoindexPost({
...createPost({ type: PostType.Article, upvotes: 10 }),
...overrides,
} as Post),
).toBe(true);
});

// The ISR fetch is unauthenticated, so posts in private squads always fail
// with FORBIDDEN. This fallback used to ship no seo, leaving them indexable.
it('should noindex the error fallback', async () => {
mockRequest.mockRejectedValue({
response: {
errors: [{ extensions: { code: ApiError.Forbidden, postId: 'p-1' } }],
},
});

const result = await getStaticProps({
params: { id: 'post-id' },
} as never);

expect(result).toMatchObject({
props: {
id: 'p-1',
seo: { noindex: true, nofollow: true },
},
});
});

it('should preserve indexed status when the author reputation is missing', () => {
expect(
shouldNoindexPost({
Expand Down
45 changes: 44 additions & 1 deletion packages/webapp/__tests__/SquadPageStaticProps.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { gqlClient } from '@dailydotdev/shared/src/graphql/common';
import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common';
import {
getSquad,
getSquadStaticFields,
Expand Down Expand Up @@ -89,4 +89,47 @@ describe('squad page getServerSideProps seo', () => {
},
});
});

it('marks a squad with an unknown visibility as noindex and nofollow', async () => {
mockStaticFields.mockResolvedValue({
...createSquad(true),
public: undefined,
});

const result = await runGssp();

expect(result).toMatchObject({
props: {
seo: {
noindex: true,
nofollow: true,
},
},
});
expect(result).not.toMatchObject({ props: { jsonLd: expect.anything() } });
});

// SSR is always unauthenticated, so the API rejects every private squad with
// FORBIDDEN before we ever see `public`. This fallback used to ship no seo at
// all, which left private squads advertised as index,follow.
it.each([ApiError.Forbidden, ApiError.NotFound, ApiError.RateLimited])(
'marks the %s fallback as noindex and nofollow',
async (code) => {
mockRequest.mockRejectedValue({
response: { errors: [{ extensions: { code } }] },
});

const result = await runGssp();

expect(result).toMatchObject({
props: {
handle: 'my-squad',
seo: {
noindex: true,
nofollow: true,
},
},
});
},
);
});
4 changes: 4 additions & 0 deletions packages/webapp/next-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,12 @@ export const TAGLINE = "Where developers discover what's next";

export const defaultSeoTitle = `daily.dev | ${TAGLINE}`;

// Shared by every logged-in Recruiter surface (dashboard, opportunities,
// candidate review, org settings, payment). None of them render for a crawler,
// and they hold customer data, so the whole product stays out of the index.
export const recruiterSeo: NextSeoProps = {
title: 'daily.dev Recruiter | Dashboard',
description:
'Your dashboard for the developer-first hiring platform. Manage roles, review matches, and track warm introductions.',
...noindexSeoProps,
};
6 changes: 6 additions & 0 deletions packages/webapp/pages/backoffice/keywords/[value].tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { NextSeoProps } from 'next-seo';
import type { ReactElement } from 'react';
import React, { useContext } from 'react';
import type {
Expand All @@ -15,6 +16,9 @@ import { gqlClient } from '@dailydotdev/shared/src/graphql/common';
import Custom404 from '../../404';
import { getLayout as getMainLayout } from '../../../components/layouts/MainLayout';
import KeywordManagement from '../../../components/KeywordManagement';
import { noindexSeoProps } from '../../../next-seo';

const seo: NextSeoProps = { ...noindexSeoProps };

export type KeywordPageProps = { keyword: string };

Expand Down Expand Up @@ -49,6 +53,8 @@ const KeywordPage = ({

KeywordPage.getLayout = getMainLayout;

KeywordPage.layoutProps = { seo };

export default KeywordPage;

export async function getStaticPaths(): Promise<GetStaticPathsResult> {
Expand Down
6 changes: 6 additions & 0 deletions packages/webapp/pages/backoffice/pendingKeywords.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { NextSeoProps } from 'next-seo';
import type { ReactElement } from 'react';
import React, { useContext } from 'react';
import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext';
Expand All @@ -9,6 +10,9 @@ import useRequirePermissions from '@dailydotdev/shared/src/hooks/useRequirePermi
import { gqlClient } from '@dailydotdev/shared/src/graphql/common';
import KeywordManagement from '../../components/KeywordManagement';
import { getLayout as getMainLayout } from '../../components/layouts/MainLayout';
import { noindexSeoProps } from '../../next-seo';

const seo: NextSeoProps = { ...noindexSeoProps };

const PendingKeywords = (): ReactElement => {
useRequirePermissions(Roles.Moderator);
Expand Down Expand Up @@ -58,4 +62,6 @@ const PendingKeywords = (): ReactElement => {

PendingKeywords.getLayout = getMainLayout;

PendingKeywords.layoutProps = { seo };

export default PendingKeywords;
6 changes: 6 additions & 0 deletions packages/webapp/pages/callback.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { NextSeoProps } from 'next-seo';
import {
broadcastMessage,
postWindowMessage,
Expand All @@ -11,6 +12,9 @@ import {
AUTH_REDIRECT_KEY,
shouldRedirectAuth,
} from '@dailydotdev/shared/src/features/onboarding/shared';
import { noindexSeoProps } from '../next-seo';

const seo: NextSeoProps = { ...noindexSeoProps };

const checkShouldSendBroadcast = () => {
const ua = navigator.userAgent;
Expand Down Expand Up @@ -94,4 +98,6 @@ function CallbackPage(): ReactElement | null {
return null;
}

CallbackPage.layoutProps = { seo };

export default CallbackPage;
4 changes: 4 additions & 0 deletions packages/webapp/pages/join/organization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { InfoIcon } from '@dailydotdev/shared/src/components/icons';
import { IconSize } from '@dailydotdev/shared/src/components/Icon';
import { getFirstQueryParam } from '@dailydotdev/shared/src/lib/func';
import Custom404Seo from '../404';
import { noindexSeoProps } from '../../next-seo';

const Page = ({
token,
Expand Down Expand Up @@ -241,4 +242,7 @@ export const getServerSideProps: GetServerSideProps<{
}
};

// Personal, token-bearing invite link: never index it.
Page.layoutProps = { seo: { ...noindexSeoProps } };

export default Page;
6 changes: 5 additions & 1 deletion packages/webapp/pages/posts/[id]/analytics/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import { seoTitle } from '../index';
import { getPageSeoTitles } from '../../../../components/layouts/utils';
import { getLayout } from '../../../../components/layouts/MainLayout';
import { getPostCanonicalUrl } from '../../../../lib/seo';
import { noindexSeoProps } from '../../../../next-seo';
import type { SharePostPageProps } from '../share';
import type { AnalyticsNumberList } from '../../../../../shared/src/components/analytics/common';

Expand Down Expand Up @@ -131,10 +132,13 @@ export const getServerSideProps: GetServerSideProps<
const pageSeoTitles = getPageSeoTitles(
[seoTitle(post), 'Analytics'].filter(Boolean).join(' | '),
);
// Only the post author can open this page, so it must never be indexed
// even though the post it belongs to is public.
const seo: NextSeoProps = {
canonical: post?.slug ? getPostCanonicalUrl(post.slug) : undefined,
title: pageSeoTitles.title,
description: getSeoDescription(post),
...noindexSeoProps,
openGraph: {
...pageSeoTitles.openGraph,
images: [
Expand Down Expand Up @@ -168,7 +172,7 @@ export const getServerSideProps: GetServerSideProps<
const { postId } = clientError.response.errors[0].extensions;

return {
props: { id: postId || id },
props: { id: postId || id, seo: { ...noindexSeoProps } },
};
}
throw err;
Expand Down
21 changes: 14 additions & 7 deletions packages/webapp/pages/posts/[id]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
PostSEOSchema,
} from '../../../components/PostSEOSchema';
import type { DynamicSeoProps } from '../../../components/common';
import { noindexSeoProps } from '../../../next-seo';
import useSharedByToast from '../../../hooks/useSharedByToast';
import { getPostCanonicalUrl } from '../../../lib/seo';

Expand Down Expand Up @@ -166,6 +167,12 @@ export const seoTitle = (post: Post): string | undefined => {
};

export const shouldNoindexPost = (post: Post): boolean => {
// Posts in private squads normally fail the unauthenticated ISR fetch, but a
// cached page can outlive a squad turning private, so fail closed here too.
if (post?.private || post?.source?.public === false) {
return true;
}

const hasLowReputationAuthor =
typeof post?.author?.reputation === 'number' &&
post.author.reputation <= 10;
Expand Down Expand Up @@ -377,12 +384,6 @@ export async function getStaticProps({
},
locale: post?.language || 'en',
},
additionalMetaTags: [
{
name: 'robots',
content: 'max-image-preview:large',
},
],
};

return {
Expand All @@ -409,8 +410,14 @@ export async function getStaticProps({

const { postId } = responseErrors?.[0]?.extensions ?? {};

// FORBIDDEN lands here for every post in a private squad, since the ISR
// fetch is unauthenticated. Without seo these fell back to index,follow.
return {
props: { id: postId || id, error: errorCode },
props: {
id: postId || id,
error: errorCode,
seo: { ...noindexSeoProps },
},
revalidate: 60,
};
}
Expand Down
6 changes: 6 additions & 0 deletions packages/webapp/pages/reset-password.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { NextSeoProps } from 'next-seo';
import type { ReactElement } from 'react';
import React from 'react';
import { useRouter } from 'next/router';
Expand All @@ -8,6 +9,9 @@ import {
Button,
ButtonVariant,
} from '@dailydotdev/shared/src/components/buttons/Button';
import { noindexSeoProps } from '../next-seo';

const seo: NextSeoProps = { ...noindexSeoProps };

const ResetPassword = (): ReactElement | null => {
const router = useRouter();
Expand Down Expand Up @@ -55,4 +59,6 @@ const ResetPassword = (): ReactElement | null => {
);
};

ResetPassword.layoutProps = { seo };

export default ResetPassword;
12 changes: 10 additions & 2 deletions packages/webapp/pages/squads/[handle]/[token].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth';
import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture';
import Link from '@dailydotdev/shared/src/components/utilities/Link';
import { getLayout } from '../../../components/layouts/MainLayout';
import { getSquadOpenGraph } from '../../../next-seo';
import { getSquadOpenGraph, noindexSeoProps } from '../../../next-seo';
import type { DynamicSeoProps } from '../../../components/common';

const getOthers = (others: Edge<SourceMember>[], total: number) => {
Expand Down Expand Up @@ -306,14 +306,22 @@ export async function getStaticProps({

if (!source || !user) {
return {
props: { handle, token, initialData: null },
props: {
handle,
token,
initialData: null,
seo: { ...noindexSeoProps },
},
};
}

// Invite tokens are personal, single-squad links (private squads included) and
// must never end up in search results.
const seo: NextSeoProps = {
title: `${user.name} invited you to ${source.name}`,
description: source.description,
openGraph: getSquadOpenGraph({ squad: source }),
...noindexSeoProps,
};

return {
Expand Down
Loading
Loading