diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-12-14 15:30:34 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-12-14 16:30:04 +0100 |
| commit | 7063b199b4748a9c354ed37e64cdc84c512f2c0c (patch) | |
| tree | 7506c3003c56b49a248e9adb40be610780bb540e | |
| parent | 85c4c42bd601270d7be0f34a0767a34bb85e29bb (diff) | |
refactor(pages): rewrite helpers to output schema in json-ld format
* make sure url are absolutes
* nest breadcrumb schema in webpage schema
* trim HTML tags from content/description
* use a regular script instead of next/script (with the latter the
schema is not updated on route change)
* place the script in document head
* add keywords, wordCount and readingTime keys in BlogPosting schema
* fix breadcrumbs in search page (without query)
* add tests (a `MatchInlineSnapshot` will be better but Prettier 3 is
not supported yet)
29 files changed, 1284 insertions, 630 deletions
diff --git a/src/components/organisms/comment/approved-comment/approved-comment.test.tsx b/src/components/organisms/comment/approved-comment/approved-comment.test.tsx index b244a63..473f845 100644 --- a/src/components/organisms/comment/approved-comment/approved-comment.test.tsx +++ b/src/components/organisms/comment/approved-comment/approved-comment.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from '@jest/globals'; import { userEvent } from '@testing-library/user-event'; import { render, screen as rtlScreen } from '../../../../../tests/utils'; +import { COMMENT_ID_PREFIX } from '../../../../utils/constants'; import { ApprovedComment, type CommentAuthor } from './approved-comment'; describe('ApprovedComment', () => { @@ -30,7 +31,7 @@ describe('ApprovedComment', () => { ).toBeInTheDocument(); expect(rtlScreen.getByRole('link')).toHaveAttribute( 'href', - `#comment-${id}` + `#${COMMENT_ID_PREFIX}${id}` ); }); diff --git a/src/components/organisms/comment/approved-comment/approved-comment.tsx b/src/components/organisms/comment/approved-comment/approved-comment.tsx index d834ba3..6611c11 100644 --- a/src/components/organisms/comment/approved-comment/approved-comment.tsx +++ b/src/components/organisms/comment/approved-comment/approved-comment.tsx @@ -1,6 +1,7 @@ import NextImage from 'next/image'; import { type ForwardRefRenderFunction, forwardRef, useCallback } from 'react'; import { useIntl } from 'react-intl'; +import { COMMENT_ID_PREFIX } from '../../../../utils/constants'; import { Button, Link, Time } from '../../../atoms'; import { Card, @@ -99,7 +100,7 @@ const ApprovedCommentWithRef: ForwardRefRenderFunction< ) => { const intl = useIntl(); const commentClass = `${className}`; - const commentId = `comment-${id}`; + const commentId = `${COMMENT_ID_PREFIX}${id}`; const commentLink = `#${commentId}`; const publicationDateLabel = intl.formatMessage({ defaultMessage: 'Published on:', diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx index 4dfe5f3..2a6ac2e 100644 --- a/src/components/templates/layout/layout.tsx +++ b/src/components/templates/layout/layout.tsx @@ -1,19 +1,11 @@ -import Script from 'next/script'; import type { FC, ReactElement, ReactNode } from 'react'; import { useIntl } from 'react-intl'; -import type { Person, SearchAction, WebSite, WithContext } from 'schema-dts'; import type { NextPageWithLayoutOptions } from '../../../types'; -import { CONFIG } from '../../../utils/config'; -import { ROUTES } from '../../../utils/constants'; import { ButtonLink, Main } from '../../atoms'; import styles from './layout.module.scss'; import { SiteFooter } from './site-footer'; import { SiteHeader, type SiteHeaderProps } from './site-header'; -export type QueryAction = SearchAction & { - 'query-input': string; -}; - export type LayoutProps = Pick<SiteHeaderProps, 'isHome'> & { /** * The layout main content. @@ -27,7 +19,6 @@ export type LayoutProps = Pick<SiteHeaderProps, 'isHome'> & { * Render the base layout used by all pages. */ export const Layout: FC<LayoutProps> = ({ children, isHome }) => { - const { baseline, copyright, locales, name, url } = CONFIG; const intl = useIntl(); const messages = { noScript: intl.formatMessage({ @@ -43,49 +34,11 @@ export const Layout: FC<LayoutProps> = ({ children, isHome }) => { }), }; - const searchActionSchema: QueryAction = { - '@type': 'SearchAction', - target: { - '@type': 'EntryPoint', - urlTemplate: `${url}${ROUTES.SEARCH}?s={search_term_string}`, - }, - query: 'required', - 'query-input': 'required name=search_term_string', - }; - const brandingSchema: Person = { - '@type': 'Person', - name, - url, - jobTitle: baseline, - image: '/armand-philippot.jpg', - subjectOf: { '@id': `${url}` }, - }; - const schemaJsonLd: WithContext<WebSite> = { - '@context': 'https://schema.org', - '@id': `${url}`, - '@type': 'WebSite', - name, - description: baseline, - url, - author: brandingSchema, - copyrightYear: Number(copyright.startYear), - creator: brandingSchema, - editor: brandingSchema, - inLanguage: locales.defaultLocale, - potentialAction: searchActionSchema, - }; - const topId = 'top'; const mainId = 'main'; return ( <> - <Script - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-layout" - type="application/ld+json" - /> <span id={topId} /> <noscript> <div className={styles['noscript-spacing']} /> diff --git a/src/components/templates/layout/site-header/site-header.tsx b/src/components/templates/layout/site-header/site-header.tsx index 3e06350..91add77 100644 --- a/src/components/templates/layout/site-header/site-header.tsx +++ b/src/components/templates/layout/site-header/site-header.tsx @@ -1,4 +1,5 @@ import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { AUTHOR_ID } from '../../../../utils/constants'; import { Header, type HeaderProps } from '../../../atoms'; import { SiteBranding } from './site-branding'; import styles from './site-header.module.scss'; @@ -16,7 +17,11 @@ const SiteHeaderWithRef: ForwardRefRenderFunction< return ( <Header {...props} className={headerClass} ref={ref}> - <SiteBranding className={styles.branding} isHome={isHome} /> + <SiteBranding + className={styles.branding} + id={AUTHOR_ID} + isHome={isHome} + /> <SiteNavbar className={styles.navbar} /> </Header> ); diff --git a/src/components/templates/page/page-comments.tsx b/src/components/templates/page/page-comments.tsx index 5f5208f..01c4eea 100644 --- a/src/components/templates/page/page-comments.tsx +++ b/src/components/templates/page/page-comments.tsx @@ -10,6 +10,7 @@ import { createComment, type CreateCommentInput, } from '../../../services/graphql'; +import { COMMENTS_SECTION_ID } from '../../../utils/constants'; import { Heading, Link, Section } from '../../atoms'; import { Card, CardBody } from '../../molecules'; import { @@ -27,7 +28,7 @@ const link = (chunks: ReactNode) => ( export type PageCommentsProps = Omit< HTMLAttributes<HTMLDivElement>, - 'children' | 'onSubmit' + 'children' | 'id' | 'onSubmit' > & Pick<CommentsListProps, 'depth'> & { /** @@ -139,7 +140,7 @@ const PageCommentsWithRef: ForwardRefRenderFunction< ); return ( - <div {...props} className={wrapperClass} ref={ref}> + <div {...props} className={wrapperClass} id={COMMENTS_SECTION_ID} ref={ref}> <Section className={styles.comments__body}> <Heading className={styles.heading} level={2}> {commentsListTitle} diff --git a/src/components/templates/page/page.tsx b/src/components/templates/page/page.tsx index b40c2f9..e3a4453 100644 --- a/src/components/templates/page/page.tsx +++ b/src/components/templates/page/page.tsx @@ -4,6 +4,7 @@ import { type HTMLAttributes, } from 'react'; import { useIntl } from 'react-intl'; +import { ARTICLE_ID } from '../../../utils/constants'; import { Article } from '../../atoms'; import { Breadcrumbs, type BreadcrumbsItem } from '../../organisms/nav'; import styles from './page.module.scss'; @@ -63,7 +64,9 @@ const PageWithRef: ForwardRefRenderFunction<HTMLDivElement, PageProps> = ( items={breadcrumbs} /> ) : null} - <Article className={pageClass}>{children}</Article> + <Article className={pageClass} id={ARTICLE_ID}> + {children} + </Article> </div> ); }; diff --git a/src/content b/src/content -Subproject 1ec792edf94bc5f69e2b92c6b020804387920d1 +Subproject bc0c32fb3b59f854768da67bb3d0b607d87b056 diff --git a/src/i18n/en.json b/src/i18n/en.json index f971c93..411bb06 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -507,6 +507,10 @@ "defaultMessage": "Contact form", "description": "Contact: form accessible name" }, + "bW6Zda": { + "defaultMessage": "Discover search results for {query} on {websiteName} website.", + "description": "SearchPage: SEO - Meta description" + }, "c0Oecl": { "defaultMessage": "Created on:", "description": "ProjectOverview: creation date label" @@ -583,6 +587,10 @@ "defaultMessage": "CC BY SA", "description": "SiteFooter: the license name" }, + "j3+hB9": { + "defaultMessage": "Home", + "description": "HomePage: page title" + }, "jJm8wd": { "defaultMessage": "Reading time:", "description": "PageHeader: reading time label" @@ -631,10 +639,6 @@ "defaultMessage": "Leave a reply to comment {id}", "description": "ReplyCommentForm: an accessible name for the reply form" }, - "npisb3": { - "defaultMessage": "Search for a post on {websiteName}.", - "description": "SearchPage: SEO - Meta description" - }, "nsw6Th": { "defaultMessage": "Copied!", "description": "usePrism: copy button text (clicked)" @@ -671,14 +675,14 @@ "defaultMessage": "Off", "description": "MotionToggle: deactivate reduce motion label" }, - "pg26sn": { - "defaultMessage": "Discover search results for {query} on {websiteName}.", - "description": "SearchPage: SEO - Meta description" - }, "qFqWQH": { "defaultMessage": "Thematics are loading...", "description": "SearchPage: loading thematics message" }, + "rEp1mS": { + "defaultMessage": "Search for a post on {websiteName} website.", + "description": "SearchPage: SEO - Meta description" + }, "rVoW4G": { "defaultMessage": "Thematics are loading...", "description": "ThematicPage: loading thematics message" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 0989e07..399abcf 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -507,6 +507,10 @@ "defaultMessage": "Formulaire de contact", "description": "Contact: form accessible name" }, + "bW6Zda": { + "defaultMessage": "Découvrez les résultats de recherche pour {query} sur le site d’{websiteName}.", + "description": "SearchPage: SEO - Meta description" + }, "c0Oecl": { "defaultMessage": "Créé le :", "description": "ProjectOverview: creation date label" @@ -583,6 +587,10 @@ "defaultMessage": "CC BY SA", "description": "SiteFooter: the license name" }, + "j3+hB9": { + "defaultMessage": "Accueil", + "description": "HomePage: page title" + }, "jJm8wd": { "defaultMessage": "Temps de lecture :", "description": "PageHeader: reading time label" @@ -631,10 +639,6 @@ "defaultMessage": "Répondre au commentaire {id}", "description": "ReplyCommentForm: an accessible name for the reply form" }, - "npisb3": { - "defaultMessage": "Rechercher un article sur {websiteName}.", - "description": "SearchPage: SEO - Meta description" - }, "nsw6Th": { "defaultMessage": "Copié !", "description": "usePrism: copy button text (clicked)" @@ -671,14 +675,14 @@ "defaultMessage": "Arrêt", "description": "MotionToggle: deactivate reduce motion label" }, - "pg26sn": { - "defaultMessage": "Découvrez les résultats de recherche pour {query} sur {websiteName}.", - "description": "SearchPage: SEO - Meta description" - }, "qFqWQH": { "defaultMessage": "Les thématiques sont en cours de chargement…", "description": "SearchPage: loading thematics message" }, + "rEp1mS": { + "defaultMessage": "Rechercher un article sur le site d’{websiteName}.", + "description": "SearchPage: SEO - Meta description" + }, "rVoW4G": { "defaultMessage": "Les thématiques sont en cours de chargement…", "description": "ThematicPage: loading thematics message" diff --git a/src/pages/404.tsx b/src/pages/404.tsx index 450859c..72c252e 100644 --- a/src/pages/404.tsx +++ b/src/pages/404.tsx @@ -1,7 +1,6 @@ import type { GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; -import Script from 'next/script'; import { useCallback, type ReactNode } from 'react'; import { useIntl } from 'react-intl'; import { @@ -34,7 +33,11 @@ import type { } from '../types'; import { CONFIG } from '../utils/config'; import { ROUTES } from '../utils/constants'; -import { getLinksItemData } from '../utils/helpers'; +import { + getLinksItemData, + getSchemaFrom, + getWebPageGraph, +} from '../utils/helpers'; import { loadTranslation, type Messages } from '../utils/helpers/server'; import { useBreadcrumbs, @@ -118,6 +121,15 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({ data }) => { messages.page.title ); + const jsonLd = getSchemaFrom([ + getWebPageGraph({ + breadcrumb: breadcrumbSchema, + description: messages.seo.metaDesc, + slug: ROUTES.NOT_FOUND, + title: messages.page.title, + }), + ]); + const searchSubmitHandler: SearchFormSubmit = useCallback( async ({ query }) => { if (!query) @@ -145,13 +157,12 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({ data }) => { <title>{messages.seo.title}</title> {/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */} <meta name="description" content={messages.seo.metaDesc} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }} - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-breadcrumb" - type="application/ld+json" - /> <PageHeader heading={messages.page.title} /> <PageBody className={styles['no-results']}> <p> diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx index 6333056..e18de75 100644 --- a/src/pages/article/[slug].tsx +++ b/src/pages/article/[slug].tsx @@ -3,7 +3,6 @@ import type { ParsedUrlQuery } from 'querystring'; import type { GetStaticPaths, GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; -import Script from 'next/script'; import { useCallback } from 'react'; import { useIntl } from 'react-intl'; import { @@ -36,11 +35,12 @@ import type { } from '../../types'; import { CONFIG } from '../../utils/config'; import { - getBlogSchema, - getCommentsSchema, - getSchemaJson, - getSinglePageSchema, - getWebPageSchema, + getBlogPostingGraph, + getCommentGraph, + getReadingTimeFrom, + getSchemaFrom, + getWebPageGraph, + trimHTMLTags, updateWordPressCodeBlocks, } from '../../utils/helpers'; import { loadTranslation, type Messages } from '../../utils/helpers/server'; @@ -129,9 +129,17 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => { [intl] ); + const flattenComments = useCallback( + (allComments: SingleComment[]): SingleComment[] => [ + ...allComments, + ...allComments.flatMap((comment) => flattenComments(comment.replies)), + ], + [] + ); + if (isFallback || isLoading) return <LoadingPage />; - const { content, id, intro, meta, title } = article; + const { content, id, intro, meta, slug, title } = article; const { author, commentsCount, @@ -143,36 +151,42 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => { wordsCount, } = meta; - const webpageSchema = getWebPageSchema({ - description: intro, - locale: CONFIG.locales.defaultLocale, - slug: article.slug, - title, - updateDate: dates.update, - }); - const blogSchema = getBlogSchema({ - isSinglePage: true, - locale: CONFIG.locales.defaultLocale, - slug: article.slug, - }); - const blogPostSchema = getSinglePageSchema({ - commentsCount, - content, - cover: cover?.src, - dates, - description: intro, - id: 'article', - kind: 'post', - locale: CONFIG.locales.defaultLocale, - slug: article.slug, - title, - }); - const schemaJsonLd = getSchemaJson([ - webpageSchema, - blogSchema, - blogPostSchema, - breadcrumbSchema, - ...getCommentsSchema(comments), + const jsonLd = getSchemaFrom([ + getWebPageGraph({ + breadcrumb: breadcrumbSchema, + copyrightYear: new Date(dates.publication).getFullYear(), + dates, + description: trimHTMLTags(intro), + slug, + title, + }), + getBlogPostingGraph({ + body: trimHTMLTags(content), + comment: flattenComments(comments).map((comment) => + getCommentGraph({ + articleSlug: slug, + author: { + '@type': 'Person', + name: comment.meta.author.name, + url: comment.meta.author.website, + }, + body: trimHTMLTags(comment.content), + id: `${comment.id}`, + parentId: comment.parentId ? `${comment.parentId}` : undefined, + publishedAt: comment.meta.date, + }) + ), + commentCount: commentsCount, + copyrightYear: new Date(dates.publication).getFullYear(), + cover: cover?.src, + dates, + description: trimHTMLTags(intro), + keywords: topics?.map((topic) => topic.name).join(', '), + readingTime: `PT${getReadingTimeFrom(wordsCount).inMinutes()}M`, + slug, + title, + wordCount: meta.wordsCount, + }), ]); const pageUrl = `${CONFIG.url}${article.slug}`; @@ -200,14 +214,12 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => { <meta property="og:type" content="article" /> <meta property="og:title" content={title} /> <meta property="og:description" content={intro} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-article" - type="application/ld+json" - // eslint-disable-next-line react/no-danger -- Necessary for schema - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> <PageHeader heading={title} intro={intro} diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index 49c16b1..f58d36f 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -1,7 +1,5 @@ -/* eslint-disable max-statements */ import type { GetStaticProps } from 'next'; import Head from 'next/head'; -import Script from 'next/script'; import { useCallback } from 'react'; import { useIntl } from 'react-intl'; import { @@ -37,13 +35,17 @@ import type { WPTopicPreview, } from '../../types'; import { CONFIG } from '../../utils/config'; -import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../utils/constants'; import { - getBlogSchema, + ARTICLE_ID, + PAGINATED_ROUTE_PREFIX, + ROUTES, +} from '../../utils/constants'; +import { + getBlogGraph, getLinksItemData, getPostsWithUrl, - getSchemaJson, - getWebPageSchema, + getSchemaFrom, + getWebPageGraph, } from '../../utils/helpers'; import { loadTranslation, type Messages } from '../../utils/helpers/server'; import { @@ -160,21 +162,23 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ data }) => { messages.pageTitle ); - const webpageSchema = getWebPageSchema({ - description: messages.seo.metaDesc, - locale: CONFIG.locales.defaultLocale, - slug: ROUTES.BLOG, - title: messages.pageTitle, - }); - const blogSchema = getBlogSchema({ - isSinglePage: false, - locale: CONFIG.locales.defaultLocale, - slug: ROUTES.BLOG, - }); - const schemaJsonLd = getSchemaJson([ - webpageSchema, - blogSchema, - breadcrumbSchema, + const jsonLd = getSchemaFrom([ + getWebPageGraph({ + breadcrumb: breadcrumbSchema, + description: messages.seo.metaDesc, + slug: ROUTES.BLOG, + title: messages.pageTitle, + }), + getBlogGraph({ + description: '', + posts: articles?.flatMap((page) => + page.edges.map(({ node }) => { + return { '@id': `${node.slug}#${ARTICLE_ID}` }; + }) + ), + slug: ROUTES.BLOG, + title: messages.pageTitle, + }), ]); const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback( @@ -235,14 +239,12 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ data }) => { <meta property="og:type" content="website" /> <meta property="og:title" content={messages.pageTitle} /> <meta property="og:description" content={messages.seo.metaDesc} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-blog" - type="application/ld+json" - // eslint-disable-next-line react/no-danger -- Necessary for schema - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> <PageHeader heading={messages.pageTitle} meta={{ total: data.posts.pageInfo.total }} diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx index 906a08e..fa1123d 100644 --- a/src/pages/blog/page/[number].tsx +++ b/src/pages/blog/page/[number].tsx @@ -3,7 +3,6 @@ import type { ParsedUrlQuery } from 'querystring'; import type { GetStaticPaths, GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; -import Script from 'next/script'; import { useCallback } from 'react'; import { useIntl } from 'react-intl'; import { @@ -44,13 +43,17 @@ import type { WPTopicPreview, } from '../../../types'; import { CONFIG } from '../../../utils/config'; -import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../../utils/constants'; import { - getBlogSchema, + ARTICLE_ID, + PAGINATED_ROUTE_PREFIX, + ROUTES, +} from '../../../utils/constants'; +import { + getBlogGraph, getLinksItemData, getPostsWithUrl, - getSchemaJson, - getWebPageSchema, + getSchemaFrom, + getWebPageGraph, } from '../../../utils/helpers'; import { loadTranslation, type Messages } from '../../../utils/helpers/server'; import { @@ -189,21 +192,23 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ messages.pageTitle ); - const webpageSchema = getWebPageSchema({ - description: messages.seo.metaDesc, - locale: CONFIG.locales.defaultLocale, - slug: ROUTES.BLOG, - title: messages.pageTitle, - }); - const blogSchema = getBlogSchema({ - isSinglePage: false, - locale: CONFIG.locales.defaultLocale, - slug: ROUTES.BLOG, - }); - const schemaJsonLd = getSchemaJson([ - webpageSchema, - blogSchema, - breadcrumbSchema, + const jsonLd = getSchemaFrom([ + getWebPageGraph({ + breadcrumb: breadcrumbSchema, + description: messages.seo.metaDesc, + slug: ROUTES.BLOG, + title: messages.pageTitle, + }), + getBlogGraph({ + description: '', + posts: articles?.flatMap((page) => + page.edges.map(({ node }) => { + return { '@id': `${node.slug}#${ARTICLE_ID}` }; + }) + ), + slug: ROUTES.BLOG, + title: messages.pageTitle, + }), ]); const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback( @@ -266,14 +271,12 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ <meta property="og:type" content="website" /> <meta property="og:title" content={messages.pageTitle} /> <meta property="og:description" content={messages.seo.metaDesc} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-blog" - type="application/ld+json" - // eslint-disable-next-line react/no-danger -- Necessary for schema - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> <PageHeader heading={messages.pageTitle} meta={{ total: data.posts.pageInfo.total }} diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx index 264ca56..7cf17f0 100644 --- a/src/pages/contact.tsx +++ b/src/pages/contact.tsx @@ -1,6 +1,5 @@ import type { GetStaticProps } from 'next'; import Head from 'next/head'; -import Script from 'next/script'; import { useCallback } from 'react'; import { useIntl } from 'react-intl'; import { @@ -19,11 +18,7 @@ import { sendEmail } from '../services/graphql'; import type { NextPageWithLayout } from '../types'; import { CONFIG } from '../utils/config'; import { ROUTES } from '../utils/constants'; -import { - getSchemaJson, - getSinglePageSchema, - getWebPageSchema, -} from '../utils/helpers'; +import { getContactPageGraph, getSchemaFrom } from '../utils/helpers'; import { loadTranslation } from '../utils/helpers/server'; import { useBreadcrumbs } from '../utils/hooks'; @@ -65,26 +60,15 @@ const ContactPage: NextPageWithLayout = () => { }, }; - const webpageSchema = getWebPageSchema({ - description: seo.description, - locale: CONFIG.locales.defaultLocale, - slug: ROUTES.CONTACT, - title: seo.title, - updateDate: dates.update, - }); - const contactSchema = getSinglePageSchema({ - dates, - description: intro, - id: 'contact', - kind: 'contact', - locale: CONFIG.locales.defaultLocale, - slug: ROUTES.CONTACT, - title, - }); - const schemaJsonLd = getSchemaJson([ - webpageSchema, - contactSchema, - breadcrumbSchema, + const jsonLd = getSchemaFrom([ + getContactPageGraph({ + breadcrumb: breadcrumbSchema, + copyrightYear: new Date(dates.publication).getFullYear(), + dates, + description: intro, + slug: ROUTES.CONTACT, + title, + }), ]); const submitMail: ContactFormSubmit = useCallback( @@ -143,13 +127,12 @@ const ContactPage: NextPageWithLayout = () => { <meta property="og:type" content="article" /> <meta property="og:title" content={title} /> <meta property="og:description" content={intro} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-contact" - type="application/ld+json" - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> <PageHeader heading={title} intro={intro} /> <PageBody> <ContactForm aria-label={messages.form} onSubmit={submitMail} /> diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx index d08c121..92c3e9e 100644 --- a/src/pages/cv.tsx +++ b/src/pages/cv.tsx @@ -1,9 +1,6 @@ -/* eslint-disable max-statements */ import type { GetStaticProps } from 'next'; import Head from 'next/head'; import NextImage from 'next/image'; -import { useRouter } from 'next/router'; -import Script from 'next/script'; import React, { type ReactNode } from 'react'; import { useIntl } from 'react-intl'; import { @@ -22,12 +19,8 @@ import { mdxComponents } from '../components/mdx'; import CVContent, { data, meta } from '../content/pages/cv.mdx'; import type { NextPageWithLayout } from '../types'; import { CONFIG } from '../utils/config'; -import { PERSONAL_LINKS } from '../utils/constants'; -import { - getSchemaJson, - getSinglePageSchema, - getWebPageSchema, -} from '../utils/helpers'; +import { PERSONAL_LINKS, ROUTES } from '../utils/constants'; +import { getAboutPageGraph, getSchemaFrom } from '../utils/helpers'; import { loadTranslation } from '../utils/helpers/server'; import { useBreadcrumbs, useHeadingsTree } from '../utils/hooks'; @@ -95,32 +88,21 @@ const CVPage: NextPageWithLayout = () => { }, }; - const { asPath } = useRouter(); - const webpageSchema = getWebPageSchema({ - description: seo.description, - locale: CONFIG.locales.defaultLocale, - slug: asPath, - title: seo.title, - updateDate: dates.update, - }); - const cvSchema = getSinglePageSchema({ - cover: data.image.src, - dates, - description: intro, - id: 'cv', - kind: 'about', - locale: CONFIG.locales.defaultLocale, - slug: asPath, - title, - }); - const schemaJsonLd = getSchemaJson([ - webpageSchema, - cvSchema, - breadcrumbSchema, + const jsonLd = getSchemaFrom([ + getAboutPageGraph({ + breadcrumb: breadcrumbSchema, + copyrightYear: new Date(dates.publication).getFullYear(), + cover: data.image.src, + dates, + description: intro, + slug: ROUTES.CV, + title, + }), ]); + const page = { title: `${seo.title} - ${CONFIG.name}`, - url: `${CONFIG.url}${asPath}`, + url: `${CONFIG.url}${ROUTES.CV}`, }; return ( @@ -136,13 +118,12 @@ const CVPage: NextPageWithLayout = () => { <meta property="og:description" content={intro} /> <meta property="og:image" content={data.image.src} /> <meta property="og:image:alt" content={title} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-cv" - type="application/ld+json" - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> <PageHeader heading={title} intro={intro} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ade628a..0e6bb23 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -2,7 +2,6 @@ import type { MDXComponents } from 'mdx/types'; import type { GetStaticProps } from 'next'; import Head from 'next/head'; import NextImage from 'next/image'; -import Script from 'next/script'; import type { FC } from 'react'; import { useIntl } from 'react-intl'; import { @@ -27,7 +26,11 @@ import { import type { NextPageWithLayout, RecentArticle } from '../types'; import { CONFIG } from '../utils/config'; import { ROUTES } from '../utils/constants'; -import { getSchemaJson, getWebPageSchema } from '../utils/helpers'; +import { + getSchemaFrom, + getWebPageGraph, + getWebSiteGraph, +} from '../utils/helpers'; import { loadTranslation, type Messages } from '../utils/helpers/server'; import { useBreadcrumbs } from '../utils/hooks'; @@ -129,15 +132,29 @@ type HomeProps = { * Home page. */ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => { + const intl = useIntl(); const { schema: breadcrumbSchema } = useBreadcrumbs(); - const webpageSchema = getWebPageSchema({ - description: meta.seo.description, - locale: CONFIG.locales.defaultLocale, - slug: ROUTES.HOME, - title: meta.seo.title, + const pageTitle = intl.formatMessage({ + defaultMessage: 'Home', + description: 'HomePage: page title', + id: 'j3+hB9', }); - const schemaJsonLd = getSchemaJson([webpageSchema, breadcrumbSchema]); + + const jsonLd = getSchemaFrom([ + getWebSiteGraph({ + description: CONFIG.baseline, + title: CONFIG.name, + }), + getWebPageGraph({ + breadcrumb: breadcrumbSchema, + copyrightYear: new Date(meta.dates.publication).getFullYear(), + dates: meta.dates, + description: meta.seo.description, + slug: ROUTES.HOME, + title: pageTitle, + }), + ]); return ( <Page hasSections> @@ -148,13 +165,12 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => { <meta property="og:url" content={CONFIG.url} /> <meta property="og:title" content={meta.seo.title} /> <meta property="og:description" content={meta.seo.description} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-homepage" - type="application/ld+json" - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> <HomePageContent components={getComponents(recentPosts)} /> </Page> ); diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx index 8613898..13fd919 100644 --- a/src/pages/mentions-legales.tsx +++ b/src/pages/mentions-legales.tsx @@ -1,6 +1,5 @@ import type { GetStaticProps } from 'next'; import Head from 'next/head'; -import Script from 'next/script'; import { useIntl } from 'react-intl'; import { getLayout, @@ -16,11 +15,7 @@ import LegalNoticeContent, { meta } from '../content/pages/legal-notice.mdx'; import type { NextPageWithLayout } from '../types'; import { CONFIG } from '../utils/config'; import { ROUTES } from '../utils/constants'; -import { - getSchemaJson, - getSinglePageSchema, - getWebPageSchema, -} from '../utils/helpers'; +import { getSchemaFrom, getWebPageGraph } from '../utils/helpers'; import { loadTranslation } from '../utils/helpers/server'; import { useBreadcrumbs, useHeadingsTree } from '../utils/hooks'; @@ -34,26 +29,15 @@ const LegalNoticePage: NextPageWithLayout = () => { useBreadcrumbs(title); const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 }); - const webpageSchema = getWebPageSchema({ - description: seo.description, - locale: CONFIG.locales.defaultLocale, - slug: ROUTES.LEGAL_NOTICE, - title: seo.title, - updateDate: dates.update, - }); - const articleSchema = getSinglePageSchema({ - dates, - description: intro, - id: 'legal-notice', - kind: 'page', - locale: CONFIG.locales.defaultLocale, - slug: ROUTES.LEGAL_NOTICE, - title, - }); - const schemaJsonLd = getSchemaJson([ - webpageSchema, - articleSchema, - breadcrumbSchema, + const jsonLd = getSchemaFrom([ + getWebPageGraph({ + breadcrumb: breadcrumbSchema, + copyrightYear: new Date(dates.publication).getFullYear(), + dates, + description: intro, + slug: ROUTES.LEGAL_NOTICE, + title, + }), ]); const page = { @@ -77,13 +61,12 @@ const LegalNoticePage: NextPageWithLayout = () => { <meta property="og:type" content="article" /> <meta property="og:title" content={page.title} /> <meta property="og:description" content={intro} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-legal-notice" - type="application/ld+json" - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> <PageHeader heading={title} intro={intro} diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx index 8985f47..1f9723a 100644 --- a/src/pages/projets/[slug].tsx +++ b/src/pages/projets/[slug].tsx @@ -1,10 +1,8 @@ -/* eslint-disable max-statements */ import type { MDXComponents } from 'mdx/types'; import type { GetStaticPaths, GetStaticProps } from 'next'; import dynamic from 'next/dynamic'; import Head from 'next/head'; import NextImage from 'next/image'; -import Script from 'next/script'; import { useMemo, type ComponentType, type FC } from 'react'; import { useIntl } from 'react-intl'; import { @@ -38,9 +36,8 @@ import type { import { CONFIG } from '../../utils/config'; import { capitalize, - getSchemaJson, - getSinglePageSchema, - getWebPageSchema, + getSchemaFrom, + getWebPageGraph, } from '../../utils/helpers'; import { type Messages, @@ -192,27 +189,16 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ data }) => { url: `${CONFIG.url}${slug}`, }; - const webpageSchema = getWebPageSchema({ - description: meta.seo.description, - locale: CONFIG.locales.defaultLocale, - slug, - title: meta.seo.title, - updateDate: meta.dates.update, - }); - const articleSchema = getSinglePageSchema({ - cover: `/projects/${id}.jpg`, - dates: meta.dates, - description: intro, - id: 'project', - kind: 'page', - locale: CONFIG.locales.defaultLocale, - slug, - title, - }); - const schemaJsonLd = getSchemaJson([ - webpageSchema, - articleSchema, - breadcrumbSchema, + const jsonLd = getSchemaFrom([ + getWebPageGraph({ + breadcrumb: breadcrumbSchema, + copyrightYear: new Date(meta.dates.publication).getFullYear(), + cover: `/projects/${id}.jpg`, + dates: meta.dates, + description: intro, + slug, + title, + }), ]); const messages = { @@ -256,14 +242,12 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ data }) => { <meta property="og:type" content="article" /> <meta property="og:title" content={title} /> <meta property="og:description" content={intro} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-project" - type="application/ld+json" - // eslint-disable-next-line react/no-danger -- Necessary for schema - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> <PageHeader heading={title} intro={intro} diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx index 401c68c..3815370 100644 --- a/src/pages/projets/index.tsx +++ b/src/pages/projets/index.tsx @@ -1,7 +1,6 @@ import type { GetStaticProps } from 'next'; import Head from 'next/head'; import NextImage from 'next/image'; -import Script from 'next/script'; import { useIntl } from 'react-intl'; import { Card, @@ -25,11 +24,7 @@ import styles from '../../styles/pages/projects.module.scss'; import type { NextPageWithLayout, ProjectPreview } from '../../types'; import { CONFIG } from '../../utils/config'; import { ROUTES } from '../../utils/constants'; -import { - getSchemaJson, - getSinglePageSchema, - getWebPageSchema, -} from '../../utils/helpers'; +import { getSchemaFrom, getWebPageGraph } from '../../utils/helpers'; import { getAllProjects, loadTranslation, @@ -52,27 +47,18 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ data }) => { const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumbs(title); const intl = useIntl(); - const webpageSchema = getWebPageSchema({ - description: seo.description, - locale: CONFIG.locales.defaultLocale, - slug: ROUTES.PROJECTS, - title: seo.title, - updateDate: dates.update, - }); - const articleSchema = getSinglePageSchema({ - dates, - description: seo.description, - id: 'projects', - kind: 'page', - locale: CONFIG.locales.defaultLocale, - slug: ROUTES.PROJECTS, - title, - }); - const schemaJsonLd = getSchemaJson([ - webpageSchema, - articleSchema, - breadcrumbSchema, + + const jsonLd = getSchemaFrom([ + getWebPageGraph({ + breadcrumb: breadcrumbSchema, + copyrightYear: new Date(dates.publication).getFullYear(), + dates, + description: seo.description, + slug: ROUTES.PROJECTS, + title, + }), ]); + const page = { title: `${seo.title} - ${CONFIG.name}`, url: `${CONFIG.url}${ROUTES.PROJECTS}`, @@ -89,14 +75,12 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ data }) => { <meta property="og:type" content="article" /> <meta property="og:title" content={page.title} /> <meta property="og:description" content={seo.description} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-projects" - type="application/ld+json" - // eslint-disable-next-line react/no-danger -- Necessary for schema - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> <PageHeader heading={title} intro={<PageContent components={mdxComponents} />} diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx index fd7f9e1..84e75af 100644 --- a/src/pages/recherche/index.tsx +++ b/src/pages/recherche/index.tsx @@ -1,8 +1,6 @@ -/* eslint-disable max-statements */ import type { GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; -import Script from 'next/script'; import { useCallback } from 'react'; import { useIntl } from 'react-intl'; import { @@ -37,11 +35,11 @@ import type { import { CONFIG } from '../../utils/config'; import { ROUTES } from '../../utils/constants'; import { - getBlogSchema, getLinksItemData, getPostsWithUrl, - getSchemaJson, - getWebPageSchema, + getSchemaFrom, + getSearchResultsPageGraph, + getWebPageGraph, } from '../../utils/helpers'; import { loadTranslation, type Messages } from '../../utils/helpers/server'; import { @@ -165,17 +163,17 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ data }) => { ? intl.formatMessage( { defaultMessage: - 'Discover search results for {query} on {websiteName}.', + 'Discover search results for {query} on {websiteName} website.', description: 'SearchPage: SEO - Meta description', - id: 'pg26sn', + id: 'bW6Zda', }, { query: query.s as string, websiteName: CONFIG.name } ) : intl.formatMessage( { - defaultMessage: 'Search for a post on {websiteName}.', + defaultMessage: 'Search for a post on {websiteName} website.', description: 'SearchPage: SEO - Meta description', - id: 'npisb3', + id: 'rEp1mS', }, { websiteName: CONFIG.name } ), @@ -213,21 +211,20 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ data }) => { const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumbs(); - const webpageSchema = getWebPageSchema({ - description: messages.seo.metaDesc, - locale: CONFIG.locales.defaultLocale, - slug: asPath, - title: messages.pageTitle, - }); - const blogSchema = getBlogSchema({ - isSinglePage: false, - locale: CONFIG.locales.defaultLocale, - slug: asPath, - }); - const schemaJsonLd = getSchemaJson([ - webpageSchema, - blogSchema, - breadcrumbSchema, + const jsonLd = getSchemaFrom([ + query.s + ? getSearchResultsPageGraph({ + breadcrumb: breadcrumbSchema, + description: messages.seo.metaDesc, + slug: asPath, + title: messages.pageTitle, + }) + : getWebPageGraph({ + breadcrumb: breadcrumbSchema, + description: messages.seo.metaDesc, + slug: asPath, + title: messages.pageTitle, + }), ]); const pageUrl = `${CONFIG.url}${asPath}`; @@ -243,14 +240,12 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ data }) => { <meta property="og:type" content="website" /> <meta property="og:title" content={messages.pageTitle} /> <meta property="og:description" content={messages.seo.title} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-blog" - type="application/ld+json" - // eslint-disable-next-line react/no-danger -- Necessary for schema - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> <PageHeader heading={messages.pageTitle} meta={{ total: articles ? articles[0].pageInfo.total : undefined }} diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx index 9d42644..af78185 100644 --- a/src/pages/sujet/[slug].tsx +++ b/src/pages/sujet/[slug].tsx @@ -4,7 +4,6 @@ import type { GetStaticPaths, GetStaticProps } from 'next'; import Head from 'next/head'; import NextImage from 'next/image'; import { useRouter } from 'next/router'; -import Script from 'next/script'; import { useIntl } from 'react-intl'; import { getLayout, @@ -37,10 +36,10 @@ import { CONFIG } from '../../utils/config'; import { getLinksItemData, getPostsWithUrl, - getSchemaJson, - getSinglePageSchema, - getWebPageSchema, + getSchemaFrom, + getWebPageGraph, slugify, + trimHTMLTags, } from '../../utils/helpers'; import { loadTranslation, type Messages } from '../../utils/helpers/server'; import { @@ -87,27 +86,16 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ data }) => { website: officialWebsite, } = meta; - const webpageSchema = getWebPageSchema({ - description: seo.description, - locale: CONFIG.locales.defaultLocale, - slug, - title: seo.title, - updateDate: dates.update, - }); - const articleSchema = getSinglePageSchema({ - cover: cover?.src, - dates, - description: intro, - id: 'topic', - kind: 'page', - locale: CONFIG.locales.defaultLocale, - slug, - title, - }); - const schemaJsonLd = getSchemaJson([ - webpageSchema, - articleSchema, - breadcrumbSchema, + const jsonLd = getSchemaFrom([ + getWebPageGraph({ + breadcrumb: breadcrumbSchema, + copyrightYear: new Date(dates.publication).getFullYear(), + cover: cover?.src, + dates, + description: trimHTMLTags(intro), + slug, + title, + }), ]); const messages = { @@ -157,14 +145,12 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ data }) => { <meta property="og:type" content="article" /> <meta property="og:title" content={title} /> <meta property="og:description" content={intro} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-project" - type="application/ld+json" - // eslint-disable-next-line react/no-danger -- Necessary for schema - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> <PageHeader heading={ <> diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx index f019341..56b956f 100644 --- a/src/pages/thematique/[slug].tsx +++ b/src/pages/thematique/[slug].tsx @@ -3,7 +3,6 @@ import type { ParsedUrlQuery } from 'querystring'; import type { GetStaticPaths, GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; -import Script from 'next/script'; import { useIntl } from 'react-intl'; import { getLayout, @@ -36,10 +35,10 @@ import { CONFIG } from '../../utils/config'; import { getLinksItemData, getPostsWithUrl, - getSchemaJson, - getSinglePageSchema, - getWebPageSchema, + getSchemaFrom, + getWebPageGraph, slugify, + trimHTMLTags, } from '../../utils/helpers'; import { loadTranslation, type Messages } from '../../utils/helpers/server'; import { @@ -79,26 +78,15 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ data }) => { const { content, intro, meta, slug, title } = thematic; const { articles, dates, seo, relatedTopics } = meta; - const webpageSchema = getWebPageSchema({ - description: seo.description, - locale: CONFIG.locales.defaultLocale, - slug, - title: seo.title, - updateDate: dates.update, - }); - const articleSchema = getSinglePageSchema({ - dates, - description: intro, - id: 'thematic', - kind: 'page', - locale: CONFIG.locales.defaultLocale, - slug, - title, - }); - const schemaJsonLd = getSchemaJson([ - webpageSchema, - articleSchema, - breadcrumbSchema, + const jsonLd = getSchemaFrom([ + getWebPageGraph({ + breadcrumb: breadcrumbSchema, + copyrightYear: new Date(dates.publication).getFullYear(), + dates, + description: trimHTMLTags(intro), + slug, + title, + }), ]); const messages = { @@ -148,14 +136,12 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ data }) => { <meta property="og:type" content="article" /> <meta property="og:title" content={title} /> <meta property="og:description" content={intro} /> + <script + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + type="application/ld+json" + /> </Head> - <Script - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-project" - type="application/ld+json" - // eslint-disable-next-line react/no-danger -- Necessary for schema - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> <PageHeader heading={title} intro={intro} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index e968f31..b6f0667 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -30,6 +30,11 @@ export const PAGINATED_ROUTE_PREFIX = '/page'; // cSpell:ignore legales thematique developpement +export const ARTICLE_ID = 'article'; +export const AUTHOR_ID = 'branding'; +export const COMMENT_ID_PREFIX = 'comment-'; +export const COMMENTS_SECTION_ID = 'comments'; + export const STORAGE_KEY = { ACKEE: 'ackee-tracking', MOTION: 'reduced-motion', diff --git a/src/utils/helpers/pages.tsx b/src/utils/helpers/pages.tsx index 24f5503..1f70e8e 100644 --- a/src/utils/helpers/pages.tsx +++ b/src/utils/helpers/pages.tsx @@ -1,7 +1,7 @@ import NextImage from 'next/image'; import type { LinksWidgetItemData, PostData } from '../../components'; import type { ArticlePreview, PageLink } from '../../types'; -import { ROUTES } from '../constants'; +import { COMMENTS_SECTION_ID, ROUTES } from '../constants'; export const getUniquePageLinks = (pageLinks: PageLink[]): PageLink[] => { const pageLinksIds = pageLinks.map((pageLink) => pageLink.id); @@ -64,7 +64,7 @@ export const getPostsWithUrl = (posts: ArticlePreview[]): PostData[] => comments: { count: meta.commentsCount ?? 0, postHeading: title, - url: `${ROUTES.ARTICLE}/${slug}#comments`, + url: `${ROUTES.ARTICLE}/${slug}#${COMMENTS_SECTION_ID}`, }, }, url: `${ROUTES.ARTICLE}/${slug}`, diff --git a/src/utils/helpers/schema-org.test.ts b/src/utils/helpers/schema-org.test.ts new file mode 100644 index 0000000..f33d408 --- /dev/null +++ b/src/utils/helpers/schema-org.test.ts @@ -0,0 +1,511 @@ +import { describe, expect, it } from '@jest/globals'; +import type { Graph } from 'schema-dts'; +import { CONFIG } from '../config'; +import { + ARTICLE_ID, + AUTHOR_ID, + COMMENTS_SECTION_ID, + COMMENT_ID_PREFIX, + ROUTES, +} from '../constants'; +import { + type WebSiteData, + getWebSiteGraph, + type WebPageData, + getWebPageGraph, + type BlogData, + getBlogGraph, + type BlogPostingData, + getBlogPostingGraph, + type CommentData, + getCommentGraph, + getAuthorGraph, + getAboutPageGraph, + getContactPageGraph, + getSearchResultsPageGraph, + getSchemaFrom, +} from './schema-org'; +import { trimTrailingChars } from './strings'; + +const host = trimTrailingChars(CONFIG.url, '/'); + +describe('getAuthorGraph', () => { + it('returns a Person schema in JSON-LD format', () => { + const result = getAuthorGraph(); + + expect(result).toStrictEqual({ + '@type': 'Person', + '@id': `${host}#${AUTHOR_ID}`, + givenName: 'Armand', + image: `${host}/armand-philippot.jpg`, + jobTitle: CONFIG.baseline, + knowsLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + { + '@type': 'Language', + name: 'English', + alternateName: 'en', + }, + { + '@type': 'Language', + name: 'Spanish', + alternateName: 'es', + }, + ], + nationality: { + '@type': 'Country', + name: 'France', + }, + name: 'Armand Philippot', + url: host, + }); + }); +}); + +describe('getWebSiteGraph', () => { + it('returns the WebSite schema in JSON-LD format', () => { + const data: WebSiteData = { + description: 'maxime ea et', + title: 'eius voluptates deserunt', + }; + const result = getWebSiteGraph(data); + + expect(result).toStrictEqual({ + '@type': 'WebSite', + '@id': host, + potentialAction: { + '@type': 'SearchAction', + query: 'required', + 'query-input': 'required name=query', + target: `${host}${ROUTES.SEARCH}?s={query}`, + }, + url: host, + author: { '@id': `${host}#${AUTHOR_ID}` }, + copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` }, + copyrightYear: Number(CONFIG.copyright.startYear), + creator: { '@id': `${host}#${AUTHOR_ID}` }, + description: data.description, + editor: { '@id': `${host}#${AUTHOR_ID}` }, + image: `${host}/icon.svg`, + inLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + ], + isAccessibleForFree: true, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + name: data.title, + publisher: { '@id': `${host}#${AUTHOR_ID}` }, + thumbnailUrl: `${host}/icon.svg`, + }); + }); +}); + +describe('getWebPageGraph', () => { + it('returns the WebPage schema in JSON-LD format', () => { + const data: WebPageData = { + breadcrumb: undefined, + copyrightYear: 2011, + cover: 'https://picsum.photos/640/480', + dates: { + publication: '2022-04-21', + update: '2023-05-02', + }, + description: 'maxime ea et', + readingTime: 'PT2M', + slug: '/harum', + title: 'eius voluptates deserunt', + }; + const result = getWebPageGraph(data); + + expect(result).toStrictEqual({ + '@id': `${host}${data.slug}`, + '@type': 'WebPage', + author: { '@id': `${host}#${AUTHOR_ID}` }, + breadcrumb: data.breadcrumb, + copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` }, + copyrightYear: data.copyrightYear, + dateCreated: data.dates?.publication, + dateModified: data.dates?.update, + datePublished: data.dates?.publication, + description: data.description, + editor: { '@id': `${host}#${AUTHOR_ID}` }, + headline: data.title, + inLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + ], + isAccessibleForFree: true, + isPartOf: { '@id': host }, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + lastReviewed: data.dates?.update, + name: data.title, + publisher: { '@id': `${host}#${AUTHOR_ID}` }, + reviewedBy: { '@id': `${host}#${AUTHOR_ID}` }, + timeRequired: data.readingTime, + thumbnailUrl: data.cover, + url: `${host}${data.slug}`, + }); + }); +}); + +describe('getAboutPageGraph', () => { + it('returns the AboutPage schema in JSON-LD format', () => { + const data: WebPageData = { + breadcrumb: undefined, + copyrightYear: 2011, + cover: 'https://picsum.photos/640/480', + dates: { + publication: '2022-04-21', + update: '2023-05-02', + }, + description: 'maxime ea et', + readingTime: 'PT2M', + slug: '/harum', + title: 'eius voluptates deserunt', + }; + const result = getAboutPageGraph(data); + + expect(result).toStrictEqual({ + '@id': `${host}${data.slug}`, + '@type': 'AboutPage', + author: { '@id': `${host}#${AUTHOR_ID}` }, + breadcrumb: data.breadcrumb, + copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` }, + copyrightYear: data.copyrightYear, + dateCreated: data.dates?.publication, + dateModified: data.dates?.update, + datePublished: data.dates?.publication, + description: data.description, + editor: { '@id': `${host}#${AUTHOR_ID}` }, + headline: data.title, + inLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + ], + isAccessibleForFree: true, + isPartOf: { '@id': host }, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + lastReviewed: data.dates?.update, + name: data.title, + publisher: { '@id': `${host}#${AUTHOR_ID}` }, + reviewedBy: { '@id': `${host}#${AUTHOR_ID}` }, + timeRequired: data.readingTime, + thumbnailUrl: data.cover, + url: `${host}${data.slug}`, + }); + }); +}); + +describe('getContactPageGraph', () => { + it('returns the ContactPage schema in JSON-LD format', () => { + const data: WebPageData = { + breadcrumb: undefined, + copyrightYear: 2011, + cover: 'https://picsum.photos/640/480', + dates: { + publication: '2022-04-21', + update: '2023-05-02', + }, + description: 'maxime ea et', + readingTime: 'PT2M', + slug: '/harum', + title: 'eius voluptates deserunt', + }; + const result = getContactPageGraph(data); + + expect(result).toStrictEqual({ + '@id': `${host}${data.slug}`, + '@type': 'ContactPage', + author: { '@id': `${host}#${AUTHOR_ID}` }, + breadcrumb: data.breadcrumb, + copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` }, + copyrightYear: data.copyrightYear, + dateCreated: data.dates?.publication, + dateModified: data.dates?.update, + datePublished: data.dates?.publication, + description: data.description, + editor: { '@id': `${host}#${AUTHOR_ID}` }, + headline: data.title, + inLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + ], + isAccessibleForFree: true, + isPartOf: { '@id': host }, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + lastReviewed: data.dates?.update, + name: data.title, + publisher: { '@id': `${host}#${AUTHOR_ID}` }, + reviewedBy: { '@id': `${host}#${AUTHOR_ID}` }, + timeRequired: data.readingTime, + thumbnailUrl: data.cover, + url: `${host}${data.slug}`, + }); + }); +}); + +describe('getSearchResultsPageGraph', () => { + it('returns the SearchResultsPage schema in JSON-LD format', () => { + const data: WebPageData = { + breadcrumb: undefined, + copyrightYear: 2011, + cover: 'https://picsum.photos/640/480', + dates: { + publication: '2022-04-21', + update: '2023-05-02', + }, + description: 'maxime ea et', + readingTime: 'PT2M', + slug: '/harum', + title: 'eius voluptates deserunt', + }; + const result = getSearchResultsPageGraph(data); + + expect(result).toStrictEqual({ + '@id': `${host}${data.slug}`, + '@type': 'SearchResultsPage', + author: { '@id': `${host}#${AUTHOR_ID}` }, + breadcrumb: data.breadcrumb, + copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` }, + copyrightYear: data.copyrightYear, + dateCreated: data.dates?.publication, + dateModified: data.dates?.update, + datePublished: data.dates?.publication, + description: data.description, + editor: { '@id': `${host}#${AUTHOR_ID}` }, + headline: data.title, + inLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + ], + isAccessibleForFree: true, + isPartOf: { '@id': host }, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + lastReviewed: data.dates?.update, + name: data.title, + publisher: { '@id': `${host}#${AUTHOR_ID}` }, + reviewedBy: { '@id': `${host}#${AUTHOR_ID}` }, + timeRequired: data.readingTime, + thumbnailUrl: data.cover, + url: `${host}${data.slug}`, + }); + }); +}); + +describe('getBlogGraph', () => { + it('returns the Blog schema in JSON-LD format', () => { + const data: BlogData = { + copyrightYear: 2013, + cover: 'https://picsum.photos/640/480', + dates: { + publication: '2021-07-01', + update: '2022-12-03', + }, + description: 'dolorem provident dolores', + posts: undefined, + readingTime: 'PT5M', + slug: '/laboriosam', + title: 'id odio rerum', + }; + const result = getBlogGraph(data); + + expect(result).toStrictEqual({ + '@type': 'Blog', + '@id': `${host}${data.slug}`, + author: { '@id': `${host}#${AUTHOR_ID}` }, + blogPost: data.posts, + copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` }, + copyrightYear: data.copyrightYear, + dateCreated: data.dates?.publication, + dateModified: data.dates?.update, + datePublished: data.dates?.publication, + description: data.description, + editor: { '@id': `${host}#${AUTHOR_ID}` }, + headline: data.title, + inLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + ], + isAccessibleForFree: true, + isPartOf: { '@id': host }, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + name: data.title, + publisher: { '@id': `${host}#${AUTHOR_ID}` }, + timeRequired: data.readingTime, + thumbnailUrl: data.cover, + url: `${host}${data.slug}`, + }); + }); +}); + +describe('getBlogPostingGraph', () => { + it('returns the BlogPosting schema in JSON-LD format', () => { + const data: BlogPostingData = { + author: undefined, + body: 'Veritatis dignissimos rerum quo est.', + comment: undefined, + commentCount: 5, + copyrightYear: 2013, + cover: 'https://picsum.photos/640/480', + dates: { + publication: '2021-07-01', + update: '2022-12-03', + }, + description: 'dolorem provident dolores', + keywords: 'unde, aut', + readingTime: 'PT5M', + slug: '/laboriosam', + title: 'id odio rerum', + wordCount: 450, + }; + const result = getBlogPostingGraph(data); + + expect(result).toStrictEqual({ + '@type': 'BlogPosting', + '@id': `${host}${data.slug}#${ARTICLE_ID}`, + articleBody: data.body, + author: { '@id': `${host}#${AUTHOR_ID}` }, + comment: data.comment, + commentCount: data.commentCount, + copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` }, + copyrightYear: data.copyrightYear, + dateCreated: data.dates?.publication, + dateModified: data.dates?.update, + datePublished: data.dates?.publication, + description: data.description, + discussionUrl: data.comment, + editor: { '@id': `${host}#${AUTHOR_ID}` }, + headline: data.title, + image: data.cover, + inLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + ], + isAccessibleForFree: true, + isPartOf: { '@id': `${host}${ROUTES.BLOG}#${ARTICLE_ID}` }, + keywords: data.keywords, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + mainEntityOfPage: { '@id': `${host}${data.slug}` }, + name: data.title, + publisher: { '@id': `${host}#${AUTHOR_ID}` }, + timeRequired: data.readingTime, + thumbnailUrl: data.cover, + url: `${host}${data.slug}`, + wordCount: data.wordCount, + }); + }); + + it('can return a discussion url', () => { + const data: BlogPostingData = { + body: 'Veritatis dignissimos rerum quo est.', + comment: [], + commentCount: 5, + description: 'dolorem provident dolores', + slug: '/laboriosam', + title: 'id odio rerum', + }; + const result = getBlogPostingGraph(data); + + expect(result.discussionUrl).toBe( + `${host}${data.slug}#${COMMENTS_SECTION_ID}` + ); + }); +}); + +describe('getCommentGraph', () => { + it('returns the Comment schema in JSON-LD format', () => { + const data: CommentData = { + articleSlug: '/maiores', + author: { + '@type': 'Person', + name: 'Horacio_Johns22', + }, + body: 'Perspiciatis maiores reiciendis tempore.', + id: 'itaque', + publishedAt: '2020-10-10', + parentId: undefined, + }; + const result = getCommentGraph(data); + + expect(result).toStrictEqual({ + '@id': `${host}${data.articleSlug}#${COMMENT_ID_PREFIX}${data.id}`, + '@type': 'Comment', + about: { '@id': `${host}/${data.articleSlug}#${ARTICLE_ID}` }, + author: data.author, + creator: data.author, + dateCreated: data.publishedAt, + datePublished: data.publishedAt, + parentItem: { '@id': `${host}/${data.articleSlug}#${ARTICLE_ID}` }, + text: data.body, + }); + }); + + it('can return a reference to the comment parent', () => { + const data: CommentData = { + articleSlug: '/maiores', + author: { + '@type': 'Person', + name: 'Horacio_Johns22', + }, + body: 'Perspiciatis maiores reiciendis tempore.', + id: 'itaque', + publishedAt: '2020-10-10', + parentId: 'magnam', + }; + const result = getCommentGraph(data); + + expect(result).toStrictEqual({ + '@id': `${host}${data.articleSlug}#${COMMENT_ID_PREFIX}${data.id}`, + '@type': 'Comment', + about: { '@id': `${host}/${data.articleSlug}#${ARTICLE_ID}` }, + author: data.author, + creator: data.author, + dateCreated: data.publishedAt, + datePublished: data.publishedAt, + parentItem: { + '@id': `${host}${data.articleSlug}#${COMMENT_ID_PREFIX}${data.parentId}`, + }, + text: data.body, + }); + }); +}); + +describe('getSchemaFrom', () => { + it('combines the given graphs with a Person graph', () => { + const graphs: Graph['@graph'] = [ + { '@type': '3DModel' }, + { '@type': 'AMRadioChannel' }, + ]; + const result = getSchemaFrom(graphs); + + expect(result).toStrictEqual({ + '@context': 'https://schema.org', + '@graph': [getAuthorGraph(), ...graphs], + }); + }); +}); diff --git a/src/utils/helpers/schema-org.ts b/src/utils/helpers/schema-org.ts index 633c35a..7710aba 100644 --- a/src/utils/helpers/schema-org.ts +++ b/src/utils/helpers/schema-org.ts @@ -1,261 +1,498 @@ import type { AboutPage, - Article, Blog, BlogPosting, + BreadcrumbList, Comment as CommentSchema, ContactPage, + Duration, Graph, + ListItem, + Person, + SearchAction, + SearchResultsPage, WebPage, + WebSite, } from 'schema-dts'; -import type { Dates, SingleComment } from '../../types'; import { CONFIG } from '../config'; -import { ROUTES } from '../constants'; +import { + ARTICLE_ID, + AUTHOR_ID, + COMMENTS_SECTION_ID, + COMMENT_ID_PREFIX, + ROUTES, +} from '../constants'; import { trimTrailingChars } from './strings'; const host = trimTrailingChars(CONFIG.url, '/'); -export type GetBlogSchemaProps = { - /** - * True if the page is part of the blog. - */ - isSinglePage: boolean; +/** + * Retrieve a Person schema in JSON-LD format for the website owner. + * + * @returns {Person} A Person graph. + */ +export const getAuthorGraph = (): Person => { + return { + '@type': 'Person', + '@id': `${host}#${AUTHOR_ID}`, + givenName: CONFIG.name.split(' ')[0], + image: `${host}/armand-philippot.jpg`, + jobTitle: CONFIG.baseline, + knowsLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + { + '@type': 'Language', + name: 'English', + alternateName: 'en', + }, + { + '@type': 'Language', + name: 'Spanish', + alternateName: 'es', + }, + ], + nationality: { + '@type': 'Country', + name: 'France', + }, + name: CONFIG.name, + url: host, + }; +}; + +export type WebSiteData = { /** - * The page locale. + * A description of the website. */ - locale: string; + description: string; /** - * The page slug with a leading slash. + * The website title. */ - slug: string; + title: string; +}; + +export type CustomSearchAction = SearchAction & { + 'query-input': string; }; /** - * Retrieve the JSON for Blog schema. + * Retrieve the Website schema in JSON-LD format. * - * @param props - The page data. - * @returns {Blog} The JSON for Blog schema. + * @param {WebSiteData} data - The website data. + * @returns {Website} A Website graph. */ -export const getBlogSchema = ({ - isSinglePage, - locale, - slug, -}: GetBlogSchemaProps): Blog => { +export const getWebSiteGraph = ({ + description, + title, +}: WebSiteData): WebSite => { + const searchAction: CustomSearchAction = { + '@type': 'SearchAction', + query: 'required', + 'query-input': 'required name=query', + target: `${host}${ROUTES.SEARCH}?s={query}`, + }; + return { - '@id': `${host}/#blog`, - '@type': 'Blog', - author: { '@id': `${host}/#branding` }, - creator: { '@id': `${host}/#branding` }, - editor: { '@id': `${host}/#branding` }, - blogPost: isSinglePage ? { '@id': `${host}/#article` } : undefined, - inLanguage: locale, - isPartOf: isSinglePage - ? { - '@id': `${host}/${slug}`, - } - : undefined, + '@type': 'WebSite', + '@id': host, + potentialAction: searchAction, + url: host, + author: { '@id': `${host}#${AUTHOR_ID}` }, + copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` }, + copyrightYear: Number(CONFIG.copyright.startYear), + creator: { '@id': `${host}#${AUTHOR_ID}` }, + description, + editor: { '@id': `${host}#${AUTHOR_ID}` }, + image: `${host}/icon.svg`, + inLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + ], + isAccessibleForFree: true, license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', - mainEntityOfPage: isSinglePage ? undefined : { '@id': `${host}/${slug}` }, + name: title, + publisher: { '@id': `${host}#${AUTHOR_ID}` }, + thumbnailUrl: `${host}/icon.svg`, }; }; +export type BreadcrumbItemData = { + label: string; + position: number; + slug: string; +}; + /** - * Retrieve the JSON for Comment schema. + * Retrieve the BreadcrumbItem schema in JSON-LD format. * - * @param props - The comments. - * @returns {CommentSchema[]} The JSON for Comment schema. + * @param {BreadcrumbItemData} data - The item data. + * @returns {ListItem} A ListItem graph. */ -export const getCommentsSchema = (comments: SingleComment[]): CommentSchema[] => - comments.map((comment) => { - return { - '@context': 'https://schema.org', - '@id': `${CONFIG.url}/#comment-${comment.id}`, - '@type': 'Comment', - parentItem: comment.parentId - ? { '@id': `${CONFIG.url}/#comment-${comment.parentId}` } - : undefined, - about: { '@type': 'Article', '@id': `${CONFIG.url}/#article` }, - author: { - '@type': 'Person', - name: comment.meta.author.name, - image: comment.meta.author.avatar?.src, - url: comment.meta.author.website, - }, - creator: { - '@type': 'Person', - name: comment.meta.author.name, - image: comment.meta.author.avatar?.src, - url: comment.meta.author.website, - }, - dateCreated: comment.meta.date, - datePublished: comment.meta.date, - text: comment.content, - }; - }); - -export type SinglePageSchemaReturn = { - about: AboutPage; - contact: ContactPage; - page: Article; - post: BlogPosting; +export const getBreadcrumbItemGraph = ({ + label, + position, + slug, +}: BreadcrumbItemData): ListItem => { + return { + '@type': 'ListItem', + item: { + '@id': slug === ROUTES.HOME ? host : `${host}${slug}`, + name: label, + }, + position, + }; }; -export type SinglePageSchemaKind = keyof SinglePageSchemaReturn; - -export type GetSinglePageSchemaProps<T extends SinglePageSchemaKind> = { +type WebContentsDates = { /** - * The number of comments. + * A date value in ISO 8601 date format. */ - commentsCount?: number; + publication?: string; /** - * The page content. + * A date value in ISO 8601 date format.. */ - content?: string; + update?: string; +}; + +type WebContentsData = { /** - * The url of the cover. + * The year during which the claimed copyright was first asserted. */ - cover?: string; + copyrightYear?: number; /** - * The page dates. + * The URL of the creative work cover. */ - dates: Dates; + cover?: string; /** - * The page description. + * A description of the contents. */ description: string; /** - * The page id. - */ - id: string; - /** - * The page kind. + * The publication date and maybe the update date. */ - kind: T; + dates?: WebContentsDates; /** - * The page locale. + * Approximate time it usually takes to work through the contents. */ - locale: string; + readingTime?: Duration; /** - * The page slug with a leading slash. + * The page slug. */ slug: string; /** - * The page title. + * The contents title. */ title: string; }; +export type WebPageData = WebContentsData & { + /** + * The breadcrumbs schema. + */ + breadcrumb?: BreadcrumbList; +}; + /** - * Retrieve the JSON schema depending on the page kind. + * Retrieve the WebPage schema in JSON-LD format. * - * @param props - The page data. - * @returns {SinglePageSchemaReturn[T]} - Either AboutPage, ContactPage, Article or BlogPosting schema. + * @param {WebPageData} data - The page data. + * @returns {WebPage} A WebPage graph. */ -export const getSinglePageSchema = <T extends SinglePageSchemaKind>({ - commentsCount, - content, +export const getWebPageGraph = ({ + breadcrumb, + copyrightYear, cover, dates, description, - id, - kind, - locale, - title, + readingTime, slug, -}: GetSinglePageSchemaProps<T>): SinglePageSchemaReturn[T] => { - const publicationDate = new Date(dates.publication); - const updateDate = dates.update ? new Date(dates.update) : undefined; - const singlePageSchemaType = { - about: 'AboutPage', - contact: 'ContactPage', - page: 'Article', - post: 'BlogPosting', + title, +}: WebPageData): WebPage => { + return { + '@id': `${host}${slug}`, + '@type': 'WebPage', + author: { '@id': `${host}#${AUTHOR_ID}` }, + breadcrumb, + copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` }, + copyrightYear, + dateCreated: dates?.publication, + dateModified: dates?.update, + datePublished: dates?.publication, + description, + editor: { '@id': `${host}#${AUTHOR_ID}` }, + headline: title, + inLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + ], + isAccessibleForFree: true, + isPartOf: { '@id': host }, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + lastReviewed: dates?.update, + name: title, + publisher: { '@id': `${host}#${AUTHOR_ID}` }, + reviewedBy: { '@id': `${host}#${AUTHOR_ID}` }, + timeRequired: readingTime, + thumbnailUrl: cover, + url: `${host}${slug}`, }; +}; +/** + * Retrieve the AboutPage schema in JSON-LD format. + * + * @param {WebPageData} data - The page data. + * @returns {AboutPage} A AboutPage graph. + */ +export const getAboutPageGraph = (data: WebPageData): AboutPage => { return { - '@id': `${host}/#${id}`, - '@type': singlePageSchemaType[kind], - name: title, + ...getWebPageGraph(data), + '@type': 'AboutPage', + }; +}; + +/** + * Retrieve the ContactPage schema in JSON-LD format. + * + * @param {WebPageData} data - The page data. + * @returns {ContactPage} A ContactPage graph. + */ +export const getContactPageGraph = (data: WebPageData): ContactPage => { + return { + ...getWebPageGraph(data), + '@type': 'ContactPage', + }; +}; + +/** + * Retrieve the SearchResultsPage schema in JSON-LD format. + * + * @param {WebPageData} data - The page data. + * @returns {SearchResultsPage} A SearchResultsPage graph. + */ +export const getSearchResultsPageGraph = ( + data: WebPageData +): SearchResultsPage => { + return { + ...getWebPageGraph(data), + '@type': 'SearchResultsPage', + }; +}; + +export type BlogData = WebContentsData & { + posts?: Blog['blogPost']; +}; + +/** + * Retrieve the Blog schema in JSON-LD format. + * + * @param {BlogData} data - The blog data. + * @returns {Blog} A Blog graph. + */ +export const getBlogGraph = ({ + copyrightYear, + cover, + dates, + description, + posts, + readingTime, + slug, + title, +}: BlogData): Blog => { + return { + '@type': 'Blog', + '@id': `${host}${slug}`, + author: { '@id': `${host}#${AUTHOR_ID}` }, + blogPost: posts, + copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` }, + copyrightYear, + dateCreated: dates?.publication, + dateModified: dates?.update, + datePublished: dates?.publication, description, - articleBody: content, - author: { '@id': `${host}/#branding` }, - commentCount: commentsCount, - copyrightYear: publicationDate.getFullYear(), - creator: { '@id': `${host}/#branding` }, - dateCreated: publicationDate.toISOString(), - dateModified: updateDate?.toISOString(), - datePublished: publicationDate.toISOString(), - editor: { '@id': `${host}/#branding` }, + editor: { '@id': `${host}#${AUTHOR_ID}` }, headline: title, - image: cover, - inLanguage: locale, + inLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + ], + isAccessibleForFree: true, + isPartOf: { '@id': host }, license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + name: title, + publisher: { '@id': `${host}#${AUTHOR_ID}` }, + timeRequired: readingTime, thumbnailUrl: cover, - isPartOf: - kind === 'post' - ? { - '@id': `${host}/${ROUTES.BLOG}`, - } - : undefined, - mainEntityOfPage: { '@id': `${host}/${slug}` }, - } as SinglePageSchemaReturn[T]; + url: `${host}${slug}`, + }; }; -export type GetWebPageSchemaProps = { +export type BlogPostingData = WebContentsData & { /** - * The page description. + * The author of the article. */ - description: string; + author?: Person; /** - * The page locale. + * The article body. */ - locale: string; + body?: string; /** - * The page slug. + * The comments on this creative work. */ - slug: string; + comment?: CommentSchema[]; /** - * The page title. + * The number of comments on this creative work. */ - title: string; + commentCount?: number; /** - * The page last update. + * A comma separated list of keywords. */ - updateDate?: string; + keywords?: string; + /** + * The number of words in the article. + */ + wordCount?: number; }; /** - * Retrieve the JSON for WebPage schema. + * Retrieve the BlogPosting schema in JSON-LD format. * - * @param props - The page data. - * @returns {WebPage} The JSON for WebPage schema. + * @param {BlogPostingData} data - The blog posting data. + * @returns {BlogPosting} A BlogPosting graph. */ -export const getWebPageSchema = ({ +export const getBlogPostingGraph = ({ + author, + body, + comment, + commentCount, + copyrightYear, + cover, + dates, description, - locale, + keywords, + readingTime, slug, title, - updateDate, -}: GetWebPageSchemaProps): WebPage => { + wordCount, +}: BlogPostingData): BlogPosting => { return { - '@id': `${host}/${slug}`, - '@type': 'WebPage', - breadcrumb: { '@id': `${host}/#breadcrumb` }, - lastReviewed: updateDate, - name: title, + '@type': 'BlogPosting', + '@id': `${host}${slug}#${ARTICLE_ID}`, + articleBody: body, + author: author ?? { '@id': `${host}#${AUTHOR_ID}` }, + comment, + commentCount, + copyrightHolder: author ?? { '@id': `${host}#${AUTHOR_ID}` }, + copyrightYear, + dateCreated: dates?.publication, + dateModified: dates?.update, + datePublished: dates?.publication, description, - inLanguage: locale, - reviewedBy: { '@id': `${host}/#branding` }, - url: `${host}/${slug}`, - isPartOf: { - '@id': `${host}`, - }, + discussionUrl: comment + ? `${host}${slug}#${COMMENTS_SECTION_ID}` + : undefined, + editor: author ?? { '@id': `${host}#${AUTHOR_ID}` }, + headline: title, + image: cover, + inLanguage: [ + { + '@type': 'Language', + name: 'French', + alternateName: 'fr', + }, + ], + isAccessibleForFree: true, + isPartOf: { '@id': `${host}${ROUTES.BLOG}#${ARTICLE_ID}` }, + keywords, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + mainEntityOfPage: { '@id': `${host}${slug}` }, + name: title, + publisher: { '@id': `${host}#${AUTHOR_ID}` }, + timeRequired: readingTime, + thumbnailUrl: cover, + url: `${host}${slug}`, + wordCount, + }; +}; + +export type CommentData = { + /** + * The slug of the commented article. + */ + articleSlug: string; + /** + * The author of the comment. + */ + author: Person; + /** + * The comment body. + */ + body: string; + /** + * The comment id. + */ + id: string; + /** + * The id of the parent. + */ + parentId?: string; + /** + * A date value in ISO 8601 date format. + */ + publishedAt: string; +}; + +/** + * Retrieve the Comment schema in JSON-LD format. + * + * @param {CommentData} data - The comment data. + * @returns {CommentSchema} A Comment graph. + */ +export const getCommentGraph = ({ + articleSlug, + author, + body, + id, + parentId, + publishedAt, +}: CommentData): CommentSchema => { + return { + '@id': `${host}${articleSlug}#${COMMENT_ID_PREFIX}${id}`, + '@type': 'Comment', + about: { '@id': `${host}/${articleSlug}#${ARTICLE_ID}` }, + author, + creator: author, + dateCreated: publishedAt, + datePublished: publishedAt, + parentItem: parentId + ? { '@id': `${host}${articleSlug}#${COMMENT_ID_PREFIX}${parentId}` } + : { '@id': `${host}/${articleSlug}#${ARTICLE_ID}` }, + text: body, }; }; -export const getSchemaJson = (graphs: Graph['@graph']): Graph => { +/** + * Retrieve a schema in JSON-LD format from the given graphs. + * + * @param {Graph['@graph']} graphs - The schema graphs. + * @returns {CommentSchema} The schema in JSON-LD format. + */ +export const getSchemaFrom = (graphs: Graph['@graph']): Graph => { return { '@context': 'https://schema.org', - '@graph': graphs, + '@graph': [getAuthorGraph(), ...graphs], }; }; diff --git a/src/utils/helpers/strings.ts b/src/utils/helpers/strings.ts index 1f40b8f..d1de8ce 100644 --- a/src/utils/helpers/strings.ts +++ b/src/utils/helpers/strings.ts @@ -60,3 +60,6 @@ export const trimTrailingChars = (str: string, char: string): string => { return str.replace(regExp, ''); }; + +export const trimHTMLTags = (str: string) => + str.replace(/(?:<(?:[^>]+)>)/gi, '').replaceAll('\n\n\n\n', '\n\n'); diff --git a/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx index 9778aed..c80db1c 100644 --- a/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx +++ b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx @@ -4,8 +4,9 @@ import nextRouterMock from 'next-router-mock'; import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'; import type { ReactNode } from 'react'; import { IntlProvider } from 'react-intl'; +import { CONFIG } from '../../config'; import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../constants'; -import { capitalize } from '../../helpers'; +import { capitalize, trimTrailingChars } from '../../helpers'; import { useBreadcrumbs } from './use-breadcrumbs'; const AllProviders = ({ children }: { children: ReactNode }) => ( @@ -48,7 +49,7 @@ describe('useBreadcrumbs', () => { { '@type': 'ListItem', item: { - '@id': ROUTES.HOME, + '@id': trimTrailingChars(CONFIG.url, '/'), name: 'Home', }, position: 1, @@ -56,7 +57,7 @@ describe('useBreadcrumbs', () => { { '@type': 'ListItem', item: { - '@id': currentSlug, + '@id': `${trimTrailingChars(CONFIG.url, '/')}${currentSlug}`, name: label, }, position: 2, diff --git a/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts index a0132c0..fd14e23 100644 --- a/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts +++ b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts @@ -4,7 +4,7 @@ import { useIntl } from 'react-intl'; import type { BreadcrumbList } from 'schema-dts'; import type { BreadcrumbsItem } from '../../../components'; import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../constants'; -import { capitalize } from '../../helpers'; +import { capitalize, getBreadcrumbItemGraph } from '../../helpers'; const is404 = (slug: string) => slug === ROUTES.NOT_FOUND; const isArticle = (slug: string) => slug === ROUTES.ARTICLE; @@ -23,7 +23,9 @@ const getCrumbsSlug = ( index: number ): string[] => [ ...acc, - ...(isSearch(`/${current}`) ? [`/${current.split('?s=')[0]}`] : []), + ...(isSearch(`/${current}`) && current.includes('?s=') + ? [`/${current.split('?s=')[0]}`] + : []), `${acc[acc.length - 1]}${index === 0 ? '' : '/'}${current}`, ]; @@ -129,16 +131,13 @@ export const useBreadcrumbs = ( schema: { '@type': 'BreadcrumbList', '@id': 'breadcrumbs', - itemListElement: items.map((item, index) => { - return { - '@type': 'ListItem', - item: { - '@id': item.slug, - name: item.label, - }, + itemListElement: items.map((item, index) => + getBreadcrumbItemGraph({ + label: item.label, position: index + 1, - }; - }), + slug: item.slug, + }) + ), }, }; }; |
