aboutsummaryrefslogtreecommitdiffstats
path: root/src/utils
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-12-14 15:30:34 +0100
committerArmand Philippot <git@armandphilippot.com>2023-12-14 16:30:04 +0100
commit7063b199b4748a9c354ed37e64cdc84c512f2c0c (patch)
tree7506c3003c56b49a248e9adb40be610780bb540e /src/utils
parent85c4c42bd601270d7be0f34a0767a34bb85e29bb (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.ts5
-rw-r--r--src/utils/helpers/pages.tsx4
-rw-r--r--src/utils/helpers/schema-org.test.ts511
-rw-r--r--src/utils/helpers/schema-org.ts561
-rw-r--r--src/utils/helpers/strings.ts3
-rw-r--r--src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx7
-rw-r--r--src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts21
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,
+ })
+ ),
},
};
};