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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/components/DefaultErrorMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const DefaultErrorMessagePage = () => {
return (
<PageContainer asChild consumeCss>
<main>
<PageTitle title={t("htmlTitles.errorPage")} />
<PageTitle title={t("htmlTitles.errorPage")} useLocationForCustomPath={true} />
<DefaultErrorMessage applySkipToContentId={true} />
</main>
</PageContainer>
Expand Down
61 changes: 53 additions & 8 deletions src/components/PageTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,46 +7,91 @@
*/

import { useContext, useEffect, useRef } from "react";
import { useHref, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import { isValidLocale } from "../i18n";
import { log } from "../util/logger/logger";
import { getAllDimensions } from "../util/trackingUtil";
import { AuthContext } from "./AuthenticationContext";
import { buildFullUrlFromPath, languagePartIndex } from "./SocialMediaMetadata";

interface Props {
interface BaseProps {
title: string;
}

interface TrackingProps {
defaultUrl?: string;
rootId?: string;
context?: {
rootId?: string;
defaultUrl?: string;
};
}

interface WithTrackingProps extends BaseProps {
trackingProps: TrackingProps;
useLocationForCustomPath?: false;
}

interface WithNoTrackingProps extends BaseProps {
trackingProps?: never;
useLocationForCustomPath: true;
}

type Props = WithTrackingProps | WithNoTrackingProps;

// NOTE: Builds the URL sent as `CustomUrl` on matomo `Pageview` events. Every
// locale segment is stripped (unlike `getCanonicalUrl`, which only strips `nb`)
// so that every language variant of the same page collapses onto a single URL
// in matomo — letting us track a page's traffic as one number regardless of
// which language the visitor used.
export const getTrackedUrl = (pathname: string) => {
const parts = pathname.split("/");
const langIdx = languagePartIndex(parts);
if (isValidLocale(parts[langIdx])) {
parts.splice(langIdx, 1);
}

return buildFullUrlFromPath(parts.join("/"));
};

/**
* A component for setting the page title and tracking a page view event.
* @param title - The title of the page. Will update the document title tag and dispatch a page view event. The component expects this title to be stable for the lifetime of the page, meaning it should not change unless the page location changes.
* @param trackingProps - Optional tracking metadata for the page view event. `defaultUrl` overrides the path used for tracking instead of the current location; `rootId` is sent as the `subjectId` dimension. Either value may also be supplied nested under `context` to accept a GraphQL `TaxonomyContext` directly.
*/
export const PageTitle = ({ title }: Props) => {
export const PageTitle = ({ title, trackingProps }: Props) => {
const { user, authContextLoaded } = useContext(AuthContext);
const { i18n } = useTranslation();
const hasTracked = useRef(false);

const location = useLocation();
const href = useHref(location);
const customPath = trackingProps?.defaultUrl ?? trackingProps?.context?.defaultUrl;
const subjectId = trackingProps?.rootId ?? trackingProps?.context?.rootId;
const trackedPath = customPath ?? location.pathname;
const trackedUrl = getTrackedUrl(trackedPath);

useEffect(() => {
hasTracked.current = false;
}, [href]);
}, [trackedUrl]);

useEffect(() => {
if (!authContextLoaded) return;
if (hasTracked.current) {
log.info("PageTitle: Page view already tracked, skipping duplicate tracking. This should not happen");
return;
}
// for debugging purposes
// log.info(`PageTitle: Tracking page view with title: ${title}`);
const dimensions = getAllDimensions({ user });
window._mtm?.push({
page_title: title,
event: "Pageview",
CustomUrl: trackedUrl,
languageCode: i18n.language,
subjectId,
...dimensions,
});
hasTracked.current = true;
}, [authContextLoaded, title, user]);
}, [authContextLoaded, title, trackedUrl, user, subjectId, i18n.language]);

return <title>{title}</title>;
};
81 changes: 52 additions & 29 deletions src/components/SocialMediaMetadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,40 @@ import { useLocation, useHref } from "react-router";
import config from "../config";
import { isValidLocale, preferredLanguages } from "../i18n";

export const buildFullUrlFromPath = (path: string) => {
return `${config.ndlaFrontendDomain}${path}`;
};

export const languagePartIndex = (parts: string[]) => {
return parts.includes("article-iframe") ? 2 : 1;
};

// NOTE: Builds the `<link rel="canonical">` URL. Only the `nb` prefix is stripped
// because bokmål is our default language and is served without a path segment;
// other locales (`nn`, `en`, …) must remain in the URL so each language variant
// has its own canonical and is indexed as a distinct page by search engines.
export const getCanonicalUrl = (pathname: string) => {
if (!pathname.includes("article-iframe")) {
return `${config.ndlaFrontendDomain}${pathname}`;
const parts = pathname.split("/");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Mens du har dette friskt i minnet: Kunne du skrevet et par kommentarer om hvorfor getCanonicalUrl og getAlternateUrl må behandles nn, en og nb forskjellig? Fort gjort å glemme når en ikke holder aktivt på med det.

const langIdx = languagePartIndex(parts);
if (parts[langIdx] === config.defaultLocale) {
parts.splice(langIdx, 1);
}
const paths = pathname.split("/");
if (isValidLocale(paths[2])) {
paths.splice(2, 1);
}
return `${config.ndlaFrontendDomain}${paths.join("/")}`;

return buildFullUrlFromPath(parts.join("/"));
};

// NOTE: Builds the `<link rel="alternate" hrefLang>` URL for a given language.
// Any existing locale segment is removed and `alternateLanguage` is inserted in
// its place so search engines can discover all language variants of a page from
// any one of them.
export const getAlternateUrl = (pathname: string, alternateLanguage: string) => {
if (!pathname.includes("article-iframe")) {
return `${config.ndlaFrontendDomain}/${alternateLanguage}${pathname}`;
}
const paths = pathname.split("/");
if (isValidLocale(paths[2])) {
paths.splice(2, 1);
const parts = pathname.split("/");
const langIdx = languagePartIndex(parts);
if (isValidLocale(parts[langIdx])) {
parts.splice(langIdx, 1);
}
paths.splice(2, 0, alternateLanguage);
return `${config.ndlaFrontendDomain}${paths.join("/")}`;
parts.splice(langIdx, 0, alternateLanguage);
return buildFullUrlFromPath(parts.join("/"));
};

export const getAlternateLanguages = (trackableContent?: TrackableContent) => {
Expand All @@ -42,43 +55,53 @@ interface TrackableContent {
supportedLanguages?: string[];
}

interface Props {
interface BaseProps {
title: string;
description?: string;
path?: string;
imageUrl?: string;
audioUrl?: string;
trackableContent?: TrackableContent;
children?: ReactNode;
type?: string;
}

interface WithCanonicalPath extends BaseProps {
canonicalPath: string | undefined;
useLocationForCanonicalPath?: false;
}

interface WithLocationForCanonicalPath extends BaseProps {
canonicalPath?: undefined;
useLocationForCanonicalPath: true;
}

type Props = WithCanonicalPath | WithLocationForCanonicalPath;

export const SocialMediaMetadata = ({
title,
imageUrl,
audioUrl,
description,
path,
trackableContent,
children,
canonicalPath,
type = "article",
}: Props) => {
const location = useLocation();
const href = useHref(location);
const hrefLocation = canonicalPath ? { pathname: canonicalPath } : location;
const href = useHref(hrefLocation);
const canonicalUrl = getCanonicalUrl(href);

return (
<>
<link rel="canonical" href={getCanonicalUrl(path ? path : location.pathname)} />
{getAlternateLanguages(trackableContent).map((alternateLanguage) => (
<link
key={alternateLanguage}
rel="alternate"
hrefLang={alternateLanguage}
href={getAlternateUrl(path ? path : location.pathname, alternateLanguage)}
/>
))}
<link rel="canonical" href={canonicalUrl} />
{getAlternateLanguages(trackableContent).map((alternateLanguage) => {
const alternateUrl = getAlternateUrl(canonicalPath ?? location.pathname, alternateLanguage);
return <link key={alternateLanguage} rel="alternate" hrefLang={alternateLanguage} href={alternateUrl} />;
})}
{children}
<meta property="og:type" content={type} />
<meta property="og:url" content={`${config.ndlaFrontendDomain}${href}`} />
<meta property="og:url" content={canonicalUrl} />
{!!title && <meta property="og:title" content={`${title} - NDLA`} />}
{!!description && <meta property="og:description" content={description} />}
{!!description && <meta name="description" content={description} />}
Expand Down
39 changes: 39 additions & 0 deletions src/components/__tests__/pageTitle-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2019-present, NDLA.
*
* This source code is licensed under the GPLv3 license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import { getTrackedUrl } from "../PageTitle";

test("getTrackedUrl with article-url and empty language", () => {
const url = getTrackedUrl("/article/123");
expect(url).toMatch("https://test.ndla.no/article/123");
});

test("getTrackedUrl with article-url and non-default language", () => {
const url = getTrackedUrl("/nn/article/123");
expect(url).toMatch("https://test.ndla.no/article/123");
});

test("getTrackedUrl with article-url and default language", () => {
const url = getTrackedUrl("/nb/article/123");
expect(url).toMatch("https://test.ndla.no/article/123");
});

test("getTrackedUrl with iframe-url and nb language", () => {
const url = getTrackedUrl("/article-iframe/nb/urn:topic:123/1");
expect(url).toMatch("https://test.ndla.no/article-iframe/urn:topic:123/1");
});

test("getTrackedUrl with iframe-url and nn language", () => {
const url = getTrackedUrl("/article-iframe/nn/urn:topic:123/1");
expect(url).toMatch("https://test.ndla.no/article-iframe/urn:topic:123/1");
});

test("getTrackedUrl with iframe-url and no language", () => {
const url = getTrackedUrl("/article-iframe/urn:topic:123/1");
expect(url).toMatch("https://test.ndla.no/article-iframe/urn:topic:123/1");
});
15 changes: 15 additions & 0 deletions src/components/__tests__/socialMediaMetadata-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,26 @@ test("getCanonicalUrl with article-url and empty language", () => {
expect(canonicalUrl).toMatch("https://test.ndla.no/article/123");
});

test("getCanonicalUrl with article-url and non-default language", () => {
const canonicalUrl = getCanonicalUrl("/nn/article/123");
expect(canonicalUrl).toMatch("https://test.ndla.no/nn/article/123");
});

test("getCanonicalUrl with article-url and default language", () => {
const canonicalUrl = getCanonicalUrl("/nb/article/123");
expect(canonicalUrl).toMatch("https://test.ndla.no/article/123");
});

test("getCanonicalUrl with iframe-url and nb language", () => {
const canonicalUrl = getCanonicalUrl("/article-iframe/nb/urn:topic:123/1");
expect(canonicalUrl).toMatch("https://test.ndla.no/article-iframe/urn:topic:123/1");
});

test("getCanonicalUrl with iframe-url and nn language", () => {
const canonicalUrl = getCanonicalUrl("/article-iframe/nn/urn:topic:123/1");
expect(canonicalUrl).toMatch("https://test.ndla.no/article-iframe/nn/urn:topic:123/1");
});

test("getCanonicalUrl with iframe-url and no language", () => {
const canonicalUrl = getCanonicalUrl("/article-iframe/urn:topic:123/1");
expect(canonicalUrl).toMatch("https://test.ndla.no/article-iframe/urn:topic:123/1");
Expand Down
3 changes: 2 additions & 1 deletion src/containers/AboutPageV2/AboutPageLeaf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export const AboutPageLeaf = ({ article: _article, crumbs }: Props) => {

return (
<main>
<PageTitle title={getDocumentTitle(t, article.title)} />
<PageTitle title={getDocumentTitle(t, article.title)} useLocationForCustomPath={true} />
<meta name="pageid" content={`${article.id}`} />
{scripts?.map((script) => (
<script key={script.src} src={script.src} type={script.type} async={script.async} defer={script.defer} />
Expand All @@ -108,6 +108,7 @@ export const AboutPageLeaf = ({ article: _article, crumbs }: Props) => {
description={article.metaDescription}
imageUrl={article.metaImage?.image.imageUrl}
trackableContent={article}
useLocationForCanonicalPath={true}
/>
<Hero variant="primary">
<HeroBackground />
Expand Down
3 changes: 2 additions & 1 deletion src/containers/AboutPageV2/AboutPageNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export const AboutPageNode = ({ article, menuItems, crumbs }: Props) => {

return (
<main>
<PageTitle title={getDocumentTitle(t, article.title)} />
<PageTitle title={getDocumentTitle(t, article.title)} useLocationForCustomPath={true} />
<meta name="pageid" content={`${article.id}`} />
{scripts?.map((script) => (
<script key={script.src} src={script.src} type={script.type} async={script.async} defer={script.defer} />
Expand All @@ -158,6 +158,7 @@ export const AboutPageNode = ({ article, menuItems, crumbs }: Props) => {
description={transformedArticle.metaDescription}
imageUrl={transformedArticle.metaImage?.image.imageUrl}
trackableContent={transformedArticle}
useLocationForCanonicalPath={true}
/>
<StyledPageContent>
<HomeBreadcrumb items={crumbs.map((bc) => ({ ...bc, to: bc.url }))} />
Expand Down
2 changes: 1 addition & 1 deletion src/containers/AccessDeniedPage/AccessDeniedPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const AccessDenied = ({ applySkipToContentId }: AccessDeniedProps) => {
return (
<Status code={statusCode}>
<ErrorMessageRoot>
<PageTitle title={t("htmlTitles.accessDenied")} />
<PageTitle title={t("htmlTitles.accessDenied")} useLocationForCustomPath={true} />
<StyledPresentationLine />
<ErrorMessageContent>
<ErrorMessageDescription id={applySkipToContentId ? SKIP_TO_CONTENT_ID : undefined}>
Expand Down
2 changes: 1 addition & 1 deletion src/containers/AllSubjectsPage/AllSubjectsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export const AllSubjectsPage = () => {
return (
<StyledPageContainer asChild consumeCss>
<main>
<PageTitle title={t("htmlTitles.subjectsPage")} />
<PageTitle title={t("htmlTitles.subjectsPage")} useLocationForCustomPath={true} />
<HeadingWrapper>
<Heading textStyle="heading.medium" id={SKIP_TO_CONTENT_ID}>
{t("subjectsPage.allSubjects")}
Expand Down
6 changes: 4 additions & 2 deletions src/containers/ArticlePage/ArticlePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const ArticlePage = ({ resource, skipToContentId, loading }: Props) => {
<>
{!!resource && !!resource.article && !!article && (
<>
<PageTitle title={getDocumentTitle(t, resource, root)} />
<PageTitle title={getDocumentTitle(t, resource, root)} trackingProps={resource} />
{scripts?.map((script) => (
<script key={script.src} src={script.src} type={script.type} async={script.async} defer={script.defer} />
))}
Expand All @@ -89,7 +89,7 @@ export const ArticlePage = ({ resource, skipToContentId, loading }: Props) => {
trackableContent={article}
description={article.metaDescription}
imageUrl={article.metaImage?.image.imageUrl}
path={resource.url}
canonicalPath={resource.url}
/>
</>
)}
Expand Down Expand Up @@ -127,13 +127,15 @@ ArticlePage.fragments = {
nodeType
name
url
defaultUrl
contentUri
relevanceId
resourceTypes {
name
id
}
context {
rootId
contextId
isArchived
parents {
Expand Down
4 changes: 2 additions & 2 deletions src/containers/CollectionPage/CollectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ const CollectionPageContent = ({ collectionLanguage, subjects, image }: Collecti
return (
<StyledPageContainer padding="large" asChild consumeCss>
<main>
<PageTitle title={pageTitle} />
<SocialMediaMetadata title={metaTitle} imageUrl={image?.image.imageUrl} />
<PageTitle title={pageTitle} useLocationForCustomPath={true} />
<SocialMediaMetadata title={metaTitle} imageUrl={image?.image.imageUrl} useLocationForCanonicalPath={true} />
<div>
{!!image && (
<StyledImage
Expand Down
Loading
Loading