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 /src/utils | |
| 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)
Diffstat (limited to 'src/utils')
| -rw-r--r-- | src/utils/constants.ts | 5 | ||||
| -rw-r--r-- | src/utils/helpers/pages.tsx | 4 | ||||
| -rw-r--r-- | src/utils/helpers/schema-org.test.ts | 511 | ||||
| -rw-r--r-- | src/utils/helpers/schema-org.ts | 561 | ||||
| -rw-r--r-- | src/utils/helpers/strings.ts | 3 | ||||
| -rw-r--r-- | src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx | 7 | ||||
| -rw-r--r-- | src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts | 21 |
7 files changed, 934 insertions, 178 deletions
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, + }) + ), }, }; }; |
