diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-11-30 19:30:43 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-12-01 16:08:54 +0100 |
| commit | 5b762b1b669454a89899c4bdf6008027d9615acf (patch) | |
| tree | 37087f4ee9d14ae131bde15a48d7d04e83ae6cbd | |
| parent | f7e6f42216c3cbeab9add475a61bb407c6be3519 (diff) | |
refactor(pages): refine Article pages
* use rehype to update code blocks class names
* fix widget heading level (after a level 1 it should always be a level
2 and not 3)
* replace Spinner with LoadingPage and LoadingPageComments components to
keep layout coherent
* refactor useArticle and useComments hooks
* fix URLs in JSON LD schema
* add Cypress tests
35 files changed, 692 insertions, 338 deletions
diff --git a/jest.setup.js b/jest.setup.js index 718f274..e3bb6f2 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -14,6 +14,7 @@ jest.mock('src/utils/helpers/rehype.ts', () => { return { __esModule: true, updateContentTree: jest.fn((str) => str), + updateWordPressCodeBlocks: jest.fn((str) => str), }; }); diff --git a/package.json b/package.json index cc0e479..3ef8bf4 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@next/mdx": "^14.0.2", "feed": "^4.2.2", "graphql": "^16.8.1", + "hast-util-classnames": "^3.0.0", "modern-normalize": "^2.0.0", "next": "^14.0.2", "next-sitemap": "^4.2.3", @@ -64,6 +65,7 @@ "sharp": "^0.32.6", "swr": "^2.2.4", "unified": "^11.0.4", + "unist-util-visit": "^5.0.0", "use-ackee": "^3.0.1" }, "devDependencies": { @@ -87,6 +89,7 @@ "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^14.1.0", "@testing-library/user-event": "^14.5.1", + "@types/hast": "^3.0.3", "@types/jest": "^29.5.8", "@types/mdx": "^2.0.10", "@types/node": "^20.9.0", @@ -124,7 +127,6 @@ "stylelint-config-standard-scss": "^11.1.0", "typescript": "^5.2.2", "undici": "^5.28.1", - "unist-util-visit": "^5.0.0", "webpack": "^5.89.0" } } diff --git a/src/components/atoms/links/sharing-link/sharing-link.module.scss b/src/components/atoms/links/sharing-link/sharing-link.module.scss index e1c9c3c..105c37f 100644 --- a/src/components/atoms/links/sharing-link/sharing-link.module.scss +++ b/src/components/atoms/links/sharing-link/sharing-link.module.scss @@ -6,7 +6,7 @@ padding: var(--spacing-2xs) var(--spacing-xs); border-radius: fun.convert-px(3); box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 var(--shadowColor); - transition: all 0.3s linear 0s; + transition: all 0.2s linear 0s; &:hover, &:focus { 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 2e29b5f..b244a63 100644 --- a/src/components/organisms/comment/approved-comment/approved-comment.test.tsx +++ b/src/components/organisms/comment/approved-comment/approved-comment.test.tsx @@ -52,7 +52,9 @@ describe('ApprovedComment', () => { /> ); - expect(rtlScreen.getByRole('img')).toHaveAccessibleName(author.avatar.alt); + expect(rtlScreen.getByRole('figure')).toHaveAccessibleName( + author.avatar.alt + ); }); it('can render a link to the author website', () => { diff --git a/src/components/organisms/comment/approved-comment/approved-comment.tsx b/src/components/organisms/comment/approved-comment/approved-comment.tsx index 233146d..d834ba3 100644 --- a/src/components/organisms/comment/approved-comment/approved-comment.tsx +++ b/src/components/organisms/comment/approved-comment/approved-comment.tsx @@ -117,9 +117,10 @@ const ApprovedCommentWithRef: ForwardRefRenderFunction< className={commentClass} cover={ author.avatar ? ( - <CardCover hasBorders> + <CardCover aria-label={author.avatar.alt} hasBorders> <NextImage - alt={author.avatar.alt} + // eslint-disable-next-line react/jsx-no-literals + alt="" height={96} src={author.avatar.src} width={96} diff --git a/src/components/templates/page/index.ts b/src/components/templates/page/index.ts index f6d2d48..f5330a7 100644 --- a/src/components/templates/page/index.ts +++ b/src/components/templates/page/index.ts @@ -1,3 +1,5 @@ +export * from './loading-page'; +export * from './loading-page-comments'; export * from './page'; export * from './page-body'; export * from './page-comments'; diff --git a/src/components/templates/page/loading-page-comments.stories.tsx b/src/components/templates/page/loading-page-comments.stories.tsx new file mode 100644 index 0000000..6069068 --- /dev/null +++ b/src/components/templates/page/loading-page-comments.stories.tsx @@ -0,0 +1,22 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { LoadingPageComments } from './loading-page-comments'; + +/** + * LoadingPageComments - Storybook Meta + */ +export default { + title: 'Templates/LoadingPageComments', + component: LoadingPageComments, + parameters: { + layout: 'fullscreen', + }, +} as ComponentMeta<typeof LoadingPageComments>; + +const Template: ComponentStory<typeof LoadingPageComments> = (args) => ( + <LoadingPageComments {...args} /> +); + +/** + * LoadingPageComments Stories - Example + */ +export const Example = Template.bind({}); diff --git a/src/components/templates/page/loading-page-comments.test.tsx b/src/components/templates/page/loading-page-comments.test.tsx new file mode 100644 index 0000000..b9ccb3e --- /dev/null +++ b/src/components/templates/page/loading-page-comments.test.tsx @@ -0,0 +1,13 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import { LoadingPageComments } from './loading-page-comments'; + +describe('LoadingPageComments', () => { + it('renders a spinner', () => { + render(<LoadingPageComments />); + + expect( + rtlScreen.getByText('The comments are loading...') + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/templates/page/loading-page-comments.tsx b/src/components/templates/page/loading-page-comments.tsx new file mode 100644 index 0000000..9235dcb --- /dev/null +++ b/src/components/templates/page/loading-page-comments.tsx @@ -0,0 +1,34 @@ +import { + forwardRef, + type ForwardRefRenderFunction, + type HTMLAttributes, +} from 'react'; +import { useIntl } from 'react-intl'; +import { Spinner } from '../../atoms'; +import styles from './page.module.scss'; + +export type LoadingPageCommentsProps = Omit< + HTMLAttributes<HTMLDivElement>, + 'children' +>; + +const LoadingPageCommentsWithRef: ForwardRefRenderFunction< + HTMLDivElement, + LoadingPageCommentsProps +> = ({ className = '', ...props }, ref) => { + const wrapperClass = `${styles.comments} ${className}`; + const intl = useIntl(); + const loadingMsg = intl.formatMessage({ + defaultMessage: 'The comments are loading...', + description: 'LoadingPageComments: loading message', + id: 'gYbxP4', + }); + + return ( + <div {...props} className={wrapperClass} ref={ref}> + <Spinner className={styles.spinner}>{loadingMsg}</Spinner> + </div> + ); +}; + +export const LoadingPageComments = forwardRef(LoadingPageCommentsWithRef); diff --git a/src/components/templates/page/loading-page.stories.tsx b/src/components/templates/page/loading-page.stories.tsx new file mode 100644 index 0000000..2ea0b33 --- /dev/null +++ b/src/components/templates/page/loading-page.stories.tsx @@ -0,0 +1,22 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { LoadingPage } from './loading-page'; + +/** + * LoadingPage - Storybook Meta + */ +export default { + title: 'Templates/LoadingPage', + component: LoadingPage, + parameters: { + layout: 'fullscreen', + }, +} as ComponentMeta<typeof LoadingPage>; + +const Template: ComponentStory<typeof LoadingPage> = (args) => ( + <LoadingPage {...args} /> +); + +/** + * LoadingPage Stories - Example + */ +export const Example = Template.bind({}); diff --git a/src/components/templates/page/loading-page.test.tsx b/src/components/templates/page/loading-page.test.tsx new file mode 100644 index 0000000..5163943 --- /dev/null +++ b/src/components/templates/page/loading-page.test.tsx @@ -0,0 +1,13 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import { LoadingPage } from './loading-page'; + +describe('LoadingPage', () => { + it('renders a spinner', () => { + render(<LoadingPage />); + + expect( + rtlScreen.getByText('The requested page is loading...') + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/templates/page/loading-page.tsx b/src/components/templates/page/loading-page.tsx new file mode 100644 index 0000000..18ceed0 --- /dev/null +++ b/src/components/templates/page/loading-page.tsx @@ -0,0 +1,28 @@ +import { forwardRef, type ForwardRefRenderFunction } from 'react'; +import { useIntl } from 'react-intl'; +import { Spinner } from '../../atoms'; +import { Page, type PageProps } from './page'; +import { PageBody } from './page-body'; +import styles from './page.module.scss'; + +const LoadingPageWithRef: ForwardRefRenderFunction< + HTMLDivElement, + Omit<PageProps, 'children'> +> = (props, ref) => { + const intl = useIntl(); + const loadingMsg = intl.formatMessage({ + defaultMessage: 'The requested page is loading...', + description: 'LoadingPage: loading message', + id: '0UzObH', + }); + + return ( + <Page {...props} ref={ref}> + <PageBody> + <Spinner className={styles.spinner}>{loadingMsg}</Spinner> + </PageBody> + </Page> + ); +}; + +export const LoadingPage = forwardRef(LoadingPageWithRef); diff --git a/src/components/templates/page/page.module.scss b/src/components/templates/page/page.module.scss index d2752a1..e7d3587 100644 --- a/src/components/templates/page/page.module.scss +++ b/src/components/templates/page/page.module.scss @@ -18,9 +18,6 @@ .section { --border-size: #{fun.convert-px(3)}; --col-gap: clamp(var(--spacing-md), 4vw, var(--spacing-2xl)); - --left-col: 0; - --right-col: 0; - --main-col: minmax(0, 80ch); @extend %grid; @@ -30,6 +27,10 @@ .breadcrumbs, .page--regular { + --left-col: 0; + --right-col: 0; + --main-col: minmax(0, 1fr); + margin-top: var(--spacing-sm); } @@ -74,7 +75,6 @@ .body { grid-column: 2; - margin-top: var(--spacing-sm); padding-bottom: var(--spacing-md); } @@ -113,8 +113,9 @@ } .section { - --right-col: minmax(0, 1fr); + --main-col: minmax(0, 80ch); --left-col: minmax(0, 1fr); + --right-col: minmax(0, 1fr); @extend %grid; @@ -202,6 +203,10 @@ } } +.spinner { + margin: var(--spacing-lg) auto 0; +} + :where(.comments) { .heading { width: fit-content; @@ -212,11 +217,16 @@ max-width: 40ch; margin-inline: auto; } + + .spinner { + grid-column: 2; + } } @container page (width > #{var.get-breakpoint("md")}) { .breadcrumbs, .page--regular { + --main-col: minmax(0, 80ch); --right-col: minmax(25ch, 1fr); } diff --git a/src/i18n/en.json b/src/i18n/en.json index be67b38..671e2b1 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -39,6 +39,10 @@ "defaultMessage": "Written by:", "description": "PageHeader: author meta label" }, + "0UzObH": { + "defaultMessage": "The requested page is loading...", + "description": "LoadingPage: loading message" + }, "0f7fty": { "defaultMessage": "Share on Diaspora", "description": "SharingWidget: Diaspora sharing link" @@ -103,10 +107,6 @@ "defaultMessage": "Page not found.", "description": "404Page: SEO - Meta description" }, - "4iYISO": { - "defaultMessage": "Loading the requested article...", - "description": "ArticlePage: loading article message" - }, "5C+1PP": { "defaultMessage": "Blog", "description": "SiteNavbar: main nav - blog link" @@ -399,6 +399,10 @@ "defaultMessage": "It has been approved.", "description": "PageComments: comment approved." }, + "VTJE8h": { + "defaultMessage": "{author}'s avatar", + "description": "Article: accessible name for the comment avatar" + }, "VkAnvv": { "defaultMessage": "Send", "description": "ContactForm: send button" @@ -523,6 +527,10 @@ "defaultMessage": "Code blocks:", "description": "PrismThemeToggle: theme label" }, + "gYbxP4": { + "defaultMessage": "The comments are loading...", + "description": "LoadingPageComments: loading message" + }, "hGvQpI": { "defaultMessage": "Load more posts?", "description": "PostsList: load more button" @@ -619,6 +627,10 @@ "defaultMessage": "Discover search results for {query} on {websiteName}.", "description": "SearchPage: SEO - Meta description" }, + "s57FTB": { + "defaultMessage": "Share", + "description": "Article: sharing widget title" + }, "s8/tyz": { "defaultMessage": "Object:", "description": "ContactForm: object label" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 0226f1e..c8b4058 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -39,6 +39,10 @@ "defaultMessage": "Écrit par :", "description": "PageHeader: author meta label" }, + "0UzObH": { + "defaultMessage": "La page est en cours de chargement…", + "description": "LoadingPage: loading message" + }, "0f7fty": { "defaultMessage": "Partager sur Diaspora", "description": "SharingWidget: Diaspora sharing link" @@ -103,10 +107,6 @@ "defaultMessage": "Page non trouvée.", "description": "404Page: SEO - Meta description" }, - "4iYISO": { - "defaultMessage": "Chargement de l’article demandé…", - "description": "ArticlePage: loading article message" - }, "5C+1PP": { "defaultMessage": "Blog", "description": "SiteNavbar: main nav - blog link" @@ -399,6 +399,10 @@ "defaultMessage": "Il a été approuvé.", "description": "PageComments: comment approved." }, + "VTJE8h": { + "defaultMessage": "Avatar de {author}", + "description": "Article: accessible name for the comment avatar" + }, "VkAnvv": { "defaultMessage": "Envoyer", "description": "ContactForm: send button" @@ -523,6 +527,10 @@ "defaultMessage": "Blocs de code :", "description": "PrismThemeToggle: theme label" }, + "gYbxP4": { + "defaultMessage": "Les commentaires sont en cours de chargement…", + "description": "LoadingPageComments: loading message" + }, "hGvQpI": { "defaultMessage": "Charger plus d’articles ?", "description": "PostsList: load more button" @@ -619,6 +627,10 @@ "defaultMessage": "Découvrez les résultats de recherche pour {query} sur {websiteName}.", "description": "SearchPage: SEO - Meta description" }, + "s57FTB": { + "defaultMessage": "Partager", + "description": "Article: sharing widget title" + }, "s8/tyz": { "defaultMessage": "Sujet :", "description": "ContactForm: object label" diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx index 04ae617..2a886aa 100644 --- a/src/pages/article/[slug].tsx +++ b/src/pages/article/[slug].tsx @@ -4,12 +4,11 @@ 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 type { Comment as CommentSchema, WithContext } from 'schema-dts'; import { getLayout, SharingWidget, - Spinner, type CommentData, Heading, Page, @@ -19,24 +18,30 @@ import { PageComments, PageSidebar, TocWidget, + LoadingPage, + LoadingPageComments, } from '../../components'; import { - convertPostToArticle, - convertWPCommentToComment, fetchAllPostsSlugs, fetchCommentsList, fetchPost, fetchPostsCount, } from '../../services/graphql'; -import styles from '../../styles/pages/article.module.scss'; -import type { Article, NextPageWithLayout, SingleComment } from '../../types'; +import styles from '../../styles/pages/blog.module.scss'; +import type { + NextPageWithLayout, + SingleComment, + WPComment, + WPPost, +} from '../../types'; import { CONFIG } from '../../utils/config'; -import { ROUTES } from '../../utils/constants'; import { getBlogSchema, + getCommentsSchema, getSchemaJson, getSinglePageSchema, getWebPageSchema, + updateWordPressCodeBlocks, } from '../../utils/helpers'; import { loadTranslation, type Messages } from '../../utils/helpers/server'; import { @@ -48,48 +53,33 @@ import { } from '../../utils/hooks'; type ArticlePageProps = { - comments: SingleComment[]; - post: Article; - slug: string; + data: { + comments: WPComment[]; + post: WPPost; + }; translation: Messages; }; /** * Article page. */ -const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ - comments, - post, - slug, -}) => { - const { isFallback } = useRouter(); +const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => { const intl = useIntl(); - const article = useArticle({ slug, fallback: post }); - const commentsData = useComments({ - fallback: comments, - first: article?.meta.commentsCount, + const { isFallback } = useRouter(); + const { article, isLoading } = useArticle(data.post.slug, data.post); + const { comments, isLoading: areCommentsLoading } = useComments({ + fallback: data.comments, + first: article.meta.commentsCount, where: { - contentId: article?.id ?? post.id, + contentId: article.id, }, }); - - const getComments = (data?: SingleComment[]) => - data?.map((comment): CommentData => { - return { - author: comment.meta.author, - content: comment.content, - id: comment.id, - isApproved: comment.isApproved, - publicationDate: comment.meta.date, - replies: getComments(comment.replies), - }; - }); - const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ - title: article?.title ?? '', - url: `${ROUTES.ARTICLE}/${slug}`, + title: data.post.title, + url: data.post.slug, }); - const { attributes, className } = usePrism({ + const { ref, tree } = useHeadingsTree({ fromLevel: 2 }); + const { attributes, className: prismClassName } = usePrism({ attributes: { 'data-toolbar-order': 'show-language,copy-to-clipboard,color-scheme', }, @@ -106,14 +96,41 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ 'line-numbers', ], }); - const loadingArticle = intl.formatMessage({ - defaultMessage: 'Loading the requested article...', - description: 'ArticlePage: loading article message', - id: '4iYISO', - }); - const { ref, tree } = useHeadingsTree({ fromLevel: 2 }); - if (isFallback || !article) return <Spinner>{loadingArticle}</Spinner>; + const formatComments = useCallback( + (allComments: SingleComment[]) => + allComments.map((comment): CommentData => { + return { + author: { + ...comment.meta.author, + avatar: comment.meta.author.avatar + ? { + ...comment.meta.author.avatar, + alt: intl.formatMessage( + { + defaultMessage: "{author}'s avatar", + description: + 'Article: accessible name for the comment avatar', + id: 'VTJE8h', + }, + { + author: comment.meta.author.name, + } + ), + } + : undefined, + }, + content: comment.content, + id: comment.id, + isApproved: comment.isApproved, + publicationDate: comment.meta.date, + replies: formatComments(comment.replies), + }; + }), + [intl] + ); + + if (isFallback || isLoading) return <LoadingPage />; const { content, id, intro, meta, title } = article; const { @@ -130,14 +147,14 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ const webpageSchema = getWebPageSchema({ description: intro, locale: CONFIG.locales.defaultLocale, - slug, + slug: article.slug, title, updateDate: dates.update, }); const blogSchema = getBlogSchema({ isSinglePage: true, locale: CONFIG.locales.defaultLocale, - slug, + slug: article.slug, }); const blogPostSchema = getSinglePageSchema({ commentsCount, @@ -148,90 +165,30 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ id: 'article', kind: 'post', locale: CONFIG.locales.defaultLocale, - slug, + slug: article.slug, title, }); - const commentsSchema: WithContext<CommentSchema>[] = commentsData - ? commentsData.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, - }; - }) - : []; const schemaJsonLd = getSchemaJson([ webpageSchema, blogSchema, blogPostSchema, - ...commentsSchema, + ...getCommentsSchema(comments), ]); - const lineNumbersClassName = className - .replace('command-line', '') - .replace(/\s\s+/g, ' '); - const commandLineClassName = className - .replace('line-numbers', '') - .replace(/\s\s+/g, ' '); - - /** - * Replace a string with Prism classnames and attributes. - * - * @param {string} str - The found string. - * @returns {string} The classes and attributes. - */ - const prismClassNameReplacer = (str: string): string => { - const wpBlockClassName = 'wp-block-code'; - const languageArray = /language-[^\s|"]+/.exec(str); - const languageClassName = languageArray ? `${languageArray[0]}` : ''; - - if ( - str.includes('command-line') || - (!str.includes('command-line') && str.includes('language-bash')) - ) { - return `class="${wpBlockClassName} ${commandLineClassName} ${languageClassName}" tabindex="0" data-filter-output="#output#`; - } - - return `class="${wpBlockClassName} ${lineNumbersClassName} ${languageClassName}" tabindex="0`; + const pageUrl = `${CONFIG.url}${article.slug}`; + const messages = { + sharingTitle: intl.formatMessage({ + defaultMessage: 'Share', + id: 's57FTB', + description: 'Article: sharing widget title', + }), + tocTitle: intl.formatMessage({ + defaultMessage: 'Table of Contents', + description: 'PageLayout: table of contents title', + id: 'eys2uX', + }), }; - const contentWithPrismClasses = content.replaceAll( - /class="wp-block-code[^"]+/gm, - prismClassNameReplacer - ); - - const pageUrl = `${CONFIG.url}${slug}`; - const sharingWidgetTitle = intl.formatMessage({ - defaultMessage: 'Share', - id: 'HKKkQk', - description: 'SharingWidget: widget title', - }); - const tocTitle = intl.formatMessage({ - defaultMessage: 'Table of Contents', - description: 'PageLayout: table of contents title', - id: 'eys2uX', - }); - const articleComments = getComments(commentsData); - return ( <Page breadcrumbs={breadcrumbItems}> <Head> @@ -270,14 +227,16 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ /> <PageSidebar> <TocWidget - heading={<Heading level={3}>{tocTitle}</Heading>} + heading={<Heading level={2}>{messages.tocTitle}</Heading>} tree={tree} /> </PageSidebar> <PageBody {...attributes} className={styles.body} - dangerouslySetInnerHTML={{ __html: contentWithPrismClasses }} + dangerouslySetInnerHTML={{ + __html: updateWordPressCodeBlocks(content, prismClassName), + }} ref={ref} /> {topics ? <PageFooter readMoreAbout={topics} /> : null} @@ -285,9 +244,9 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ <SharingWidget // eslint-disable-next-line react/jsx-no-literals -- Key allowed key="sharing-widget" - className={styles.widget} + className={styles['sharing-widget']} data={{ excerpt: intro, title, url: pageUrl }} - heading={<Heading level={3}>{sharingWidgetTitle}</Heading>} + heading={<Heading level={2}>{messages.sharingTitle}</Heading>} media={[ 'diaspora', 'email', @@ -298,7 +257,15 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ ]} /> </PageSidebar> - <PageComments comments={articleComments ?? []} depth={2} pageId={id} /> + {areCommentsLoading ? ( + <LoadingPageComments /> + ) : ( + <PageComments + comments={formatComments(comments)} + depth={2} + pageId={id} + /> + )} </Page> ); }; @@ -314,7 +281,6 @@ export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({ params, }) => { const post = await fetchPost((params as PostParams).slug); - const article = await convertPostToArticle(post); const comments = await fetchCommentsList({ first: post.commentCount ?? 1, where: { contentId: post.databaseId }, @@ -323,11 +289,10 @@ export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({ return { props: { - comments: JSON.parse( - JSON.stringify(comments.map(convertWPCommentToComment)) - ), - post: JSON.parse(JSON.stringify(article)), - slug: post.slug, + data: { + comments: JSON.parse(JSON.stringify(comments)), + post: JSON.parse(JSON.stringify(post)), + }, translation, }, }; diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx index 3d1e966..487b18b 100644 --- a/src/pages/thematique/[slug].tsx +++ b/src/pages/thematique/[slug].tsx @@ -59,7 +59,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ const webpageSchema = getWebPageSchema({ description: seo.description, locale: CONFIG.locales.defaultLocale, - slug: asPath, + slug, title: seo.title, updateDate: dates.update, }); @@ -69,7 +69,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ id: 'thematic', kind: 'page', locale: CONFIG.locales.defaultLocale, - slug: asPath, + slug, title, }); const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]); diff --git a/src/services/graphql/helpers/convert-post-to-article.test.ts b/src/services/graphql/helpers/convert-post-to-article.test.ts index 0a1c359..9fd74af 100644 --- a/src/services/graphql/helpers/convert-post-to-article.test.ts +++ b/src/services/graphql/helpers/convert-post-to-article.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from '@jest/globals'; import type { WPPost } from '../../../types'; +import { ROUTES } from '../../../utils/constants'; import { convertPostToArticle } from './convert-post-to-article'; import { convertWPImgToImg } from './convert-wp-image-to-img'; describe('convert-post-to-article', () => { /* eslint-disable max-statements */ - it('converts a WPPost object to an Article object', async () => { + it('converts a WPPost object to an Article object', () => { const post: WPPost = { acfPosts: null, author: { node: { name: 'Vince5' } }, @@ -28,10 +29,7 @@ describe('convert-post-to-article', () => { slug: '/the-post-slug', title: 'ea vero repellat', }; - const result = await convertPostToArticle(post); - - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - expect.assertions(15); + const result = convertPostToArticle(post); expect(result.content).toBe(post.contentParts.afterMore); expect(result.id).toBe(post.databaseId); @@ -46,12 +44,12 @@ describe('convert-post-to-article', () => { expect(result.meta.thematics).toBeUndefined(); expect(result.meta.topics).toBeUndefined(); expect(result.meta.wordsCount).toBe(post.info.wordsCount); - expect(result.slug).toBe(post.slug); + expect(result.slug).toBe(`${ROUTES.ARTICLE}/${post.slug}`); expect(result.title).toBe(post.title); }); /* eslint-enable max-statements */ - it('can convert the cover', async () => { + it('can convert the cover', () => { const post = { acfPosts: null, author: { node: { name: 'Vince5' } }, @@ -84,16 +82,14 @@ describe('convert-post-to-article', () => { slug: '/the-post-slug', title: 'ea vero repellat', } satisfies WPPost; - const result = await convertPostToArticle(post); - - expect.assertions(1); + const result = convertPostToArticle(post); expect(result.meta.cover).toStrictEqual( convertWPImgToImg(post.featuredImage.node) ); }); - it('can return 0 as comment count when not defined', async () => { + it('can return 0 as comment count when not defined', () => { const post: WPPost = { acfPosts: null, author: { node: { name: 'Vince5' } }, @@ -116,9 +112,7 @@ describe('convert-post-to-article', () => { slug: '/the-post-slug', title: 'ea vero repellat', }; - const result = await convertPostToArticle(post); - - expect.assertions(1); + const result = convertPostToArticle(post); expect(result.meta.commentsCount).toBe(0); }); diff --git a/src/services/graphql/helpers/convert-post-to-article.ts b/src/services/graphql/helpers/convert-post-to-article.ts index 383dc47..14c572d 100644 --- a/src/services/graphql/helpers/convert-post-to-article.ts +++ b/src/services/graphql/helpers/convert-post-to-article.ts @@ -1,4 +1,5 @@ import type { Article, WPPost } from '../../../types'; +import { ROUTES } from '../../../utils/constants'; import { updateContentTree } from '../../../utils/helpers'; import { convertWPThematicPreviewToPageLink, @@ -6,7 +7,7 @@ import { } from './convert-taxonomy-to-page-link'; import { convertWPImgToImg } from './convert-wp-image-to-img'; -export const convertPostToArticle = async ({ +export const convertPostToArticle = ({ acfPosts, author, commentCount, @@ -19,9 +20,9 @@ export const convertPostToArticle = async ({ seo, slug, title, -}: WPPost): Promise<Article> => { +}: WPPost): Article => { return { - content: await updateContentTree(contentParts.afterMore), + content: updateContentTree(contentParts.afterMore), id: databaseId, intro: contentParts.beforeMore, meta: { @@ -42,7 +43,7 @@ export const convertPostToArticle = async ({ topics: acfPosts?.postsInTopic?.map(convertWPTopicPreviewToPageLink), wordsCount: info.wordsCount, }, - slug, + slug: `${ROUTES.ARTICLE}/${slug}`, title, }; }; diff --git a/src/styles/pages/article.module.scss b/src/styles/pages/article.module.scss deleted file mode 100644 index 7aac5a7..0000000 --- a/src/styles/pages/article.module.scss +++ /dev/null @@ -1,74 +0,0 @@ -@use "../abstracts/functions" as fun; -@use "../abstracts/mixins" as mix; -@use "../abstracts/placeholders"; -@use "partials/article-headings"; -@use "partials/article-links"; -@use "partials/article-lists"; -@use "partials/article-media"; -@use "partials/article-wp-blocks"; - -.btn { - margin-right: var(--spacing-2xs); - padding: var(--spacing-2xs) var(--spacing-xs); - - img { - max-width: fun.convert-px(22); - } -} - -.body { - :global { - @include article-headings.styles; - @include article-links.styles; - @include article-lists.styles; - @include article-media.styles; - @include article-wp-blocks.styles; - @extend %prism; - } -} - -:global([data-theme="light"]) { - :local { - .body { - :global { - a { - &.download { - @extend %light-download-link; - } - - &.external { - @extend %light-external-link; - } - } - } - } - } -} - -:global([data-theme="dark"]) { - :local { - .body { - :global { - a { - &.download { - @extend %dark-download-link; - } - - &.external { - @extend %dark-external-link; - } - } - } - } - } -} - -.widget { - @include mix.media("screen") { - @include mix.dimensions("md") { - ul { - width: min-content; - } - } - } -} diff --git a/src/styles/pages/blog.module.scss b/src/styles/pages/blog.module.scss index e099088..aebf263 100644 --- a/src/styles/pages/blog.module.scss +++ b/src/styles/pages/blog.module.scss @@ -1,7 +1,11 @@ @use "../abstracts/functions" as fun; @use "../abstracts/mixins" as mix; @use "../abstracts/placeholders"; +@use "partials/article-headings"; @use "partials/article-links"; +@use "partials/article-lists"; +@use "partials/article-media"; +@use "partials/article-wp-blocks"; .list { @include mix.media("screen") { @@ -14,20 +18,29 @@ } } +.sharing-widget { + @include mix.media("screen") { + @include mix.dimensions("md") { + ul { + width: min-content; + } + } + } +} + .logo { max-width: fun.convert-px(50); margin: 0 var(--spacing-xs) 0 0; } -:where(.body) { +.body { :global { + @include article-headings.styles; @include article-links.styles; - - h2 { - @extend %h2; - - margin-block-end: var(--spacing-sm); - } + @include article-lists.styles; + @include article-media.styles; + @include article-wp-blocks.styles; + @extend %prism; } } diff --git a/src/styles/pages/partials/_article-headings.scss b/src/styles/pages/partials/_article-headings.scss index 7a273e4..234c426 100644 --- a/src/styles/pages/partials/_article-headings.scss +++ b/src/styles/pages/partials/_article-headings.scss @@ -31,8 +31,10 @@ h4, h5, h6 { - &:not(:first-child) { - margin-block: var(--spacing-sm); + margin-block: var(--spacing-sm); + + &:first-of-type { + margin-block-start: var(--spacing-md); } } } diff --git a/src/styles/pages/partials/_article-wp-blocks.scss b/src/styles/pages/partials/_article-wp-blocks.scss index f23be05..e4e89ec 100644 --- a/src/styles/pages/partials/_article-wp-blocks.scss +++ b/src/styles/pages/partials/_article-wp-blocks.scss @@ -75,7 +75,7 @@ display: flex; flex-flow: column; width: fit-content; - margin: 0 auto; + margin: var(--spacing-sm) auto; position: relative; text-align: center; @@ -172,9 +172,13 @@ } } - .wp-block-image img { - height: 100%; - object-fit: cover; + .wp-block-image { + margin: 0; + + img { + height: 100%; + object-fit: cover; + } } } } diff --git a/src/utils/helpers/rehype.ts b/src/utils/helpers/rehype.ts index fc51da1..f061fc2 100644 --- a/src/utils/helpers/rehype.ts +++ b/src/utils/helpers/rehype.ts @@ -1,3 +1,11 @@ +import type Hast from 'hast'; +import { classnames } from 'hast-util-classnames'; +import rehypeParse from 'rehype-parse'; +import rehypeSlug from 'rehype-slug'; +import rehypeStringify from 'rehype-stringify'; +import { unified, type Plugin as UnifiedPlugin } from 'unified'; +import { visit } from 'unist-util-visit'; + /** * Update a stringified HTML tree using unified plugins. * @@ -6,16 +14,83 @@ * @param {string} content - The page contents. * @returns {string} The updated page contents. */ -export const updateContentTree = async (content: string): Promise<string> => { - const { unified } = await import('unified'); - const rehypeParse = (await import('rehype-parse')).default; - const rehypeSlug = (await import('rehype-slug')).default; - const rehypeStringify = (await import('rehype-stringify')).default; - - return unified() +export const updateContentTree = (content: string): string => + unified() .use(rehypeParse, { fragment: true }) .use(rehypeSlug) .use(rehypeStringify) .processSync(content) .toString(); + +const isSubStrIn = (substr: string | RegExp, str: string) => { + if (typeof substr === 'string') return str.includes(substr); + + return substr.test(str); }; + +const isNodeContainsClass = ( + node: Hast.Element, + className: string | RegExp +) => { + if (Array.isArray(node.properties.className)) { + return node.properties.className.some( + (singleClass) => + typeof singleClass === 'string' && isSubStrIn(className, singleClass) + ); + } + + if (typeof node.properties.className === 'string') + return isSubStrIn(className, node.properties.className); + + return false; +}; + +const rehypePrismClass: UnifiedPlugin< + Record<'className', string>[], + Hast.Root +> = + ({ className }) => + (tree) => { + const wpBlockClassName = 'wp-block-code'; + const lineNumbersClassName = className + .replace('command-line', '') + .replace(/\s\s+/g, ' '); + const commandLineClassName = className + .replace('line-numbers', '') + .replace(/\s\s+/g, ' '); + + visit(tree, 'element', (node) => { + if ( + node.tagName === 'pre' && + isNodeContainsClass(node, wpBlockClassName) + ) { + if (isNodeContainsClass(node, 'language-bash')) { + classnames(node, commandLineClassName); + node.properties['data-filter-output'] = '#output#'; + } else if (isNodeContainsClass(node, /language-/)) { + classnames(node, lineNumbersClassName); + } + } + }); + }; + +/** + * Update a stringified HTML tree using unified plugins. + * + * It will parse the provided content to update the classnames of WordPress + * code blocks. + * + * @param {string} content - The page contents. + * @param {string} className - The prism classNames. + * @returns {string} The updated page contents. + */ +export const updateWordPressCodeBlocks = ( + content: string, + className: string +): string => + unified() + .use(rehypeParse, { fragment: true }) + .use(rehypePrismClass, { className }) + .use(rehypeStringify) + .processSync(content) + .toString(); diff --git a/src/utils/helpers/schema-org.ts b/src/utils/helpers/schema-org.ts index f028f5a..633c35a 100644 --- a/src/utils/helpers/schema-org.ts +++ b/src/utils/helpers/schema-org.ts @@ -3,11 +3,12 @@ import type { Article, Blog, BlogPosting, + Comment as CommentSchema, ContactPage, Graph, WebPage, } from 'schema-dts'; -import type { Dates } from '../../types'; +import type { Dates, SingleComment } from '../../types'; import { CONFIG } from '../config'; import { ROUTES } from '../constants'; import { trimTrailingChars } from './strings'; @@ -50,14 +51,48 @@ export const getBlogSchema = ({ inLanguage: locale, isPartOf: isSinglePage ? { - '@id': `${host}${slug}`, + '@id': `${host}/${slug}`, } : undefined, license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', - mainEntityOfPage: isSinglePage ? undefined : { '@id': `${host}${slug}` }, + mainEntityOfPage: isSinglePage ? undefined : { '@id': `${host}/${slug}` }, }; }; +/** + * Retrieve the JSON for Comment schema. + * + * @param props - The comments. + * @returns {CommentSchema[]} The JSON for Comment schema. + */ +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; @@ -159,10 +194,10 @@ export const getSinglePageSchema = <T extends SinglePageSchemaKind>({ isPartOf: kind === 'post' ? { - '@id': `${host}${ROUTES.BLOG}`, + '@id': `${host}/${ROUTES.BLOG}`, } : undefined, - mainEntityOfPage: { '@id': `${host}${slug}` }, + mainEntityOfPage: { '@id': `${host}/${slug}` }, } as SinglePageSchemaReturn[T]; }; @@ -203,7 +238,7 @@ export const getWebPageSchema = ({ updateDate, }: GetWebPageSchemaProps): WebPage => { return { - '@id': `${host}${slug}`, + '@id': `${host}/${slug}`, '@type': 'WebPage', breadcrumb: { '@id': `${host}/#breadcrumb` }, lastReviewed: updateDate, @@ -211,7 +246,7 @@ export const getWebPageSchema = ({ description, inLanguage: locale, reviewedBy: { '@id': `${host}/#branding` }, - url: `${host}${slug}`, + url: `${host}/${slug}`, isPartOf: { '@id': `${host}`, }, diff --git a/src/utils/hooks/use-article.ts b/src/utils/hooks/use-article.ts deleted file mode 100644 index 5e54ee4..0000000 --- a/src/utils/hooks/use-article.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect, useState } from 'react'; -import useSWR from 'swr'; -import { convertPostToArticle, fetchPost } from '../../services/graphql'; -import type { Article, Maybe } from '../../types'; - -export type UseArticleConfig = { - /** - * A fallback article - */ - fallback?: Article; - /** - * The article slug - */ - slug?: string; -}; - -/** - * Retrieve an article by slug. - * - * @param {UseArticleConfig} config - The config. - * @returns {Article|undefined} The matching article if it exists. - */ -export const useArticle = ({ - slug, - fallback, -}: UseArticleConfig): Article | undefined => { - const { data } = useSWR(slug, fetchPost, {}); - const [article, setArticle] = useState<Maybe<Article>>(fallback); - - useEffect(() => { - if (data) convertPostToArticle(data).then((post) => setArticle(post)); - }, [data]); - - return article; -}; diff --git a/src/utils/hooks/use-article/index.ts b/src/utils/hooks/use-article/index.ts new file mode 100644 index 0000000..459fc6d --- /dev/null +++ b/src/utils/hooks/use-article/index.ts @@ -0,0 +1 @@ +export * from './use-article'; diff --git a/src/utils/hooks/use-article/use-article.test.ts b/src/utils/hooks/use-article/use-article.test.ts new file mode 100644 index 0000000..7c9574c --- /dev/null +++ b/src/utils/hooks/use-article/use-article.test.ts @@ -0,0 +1,54 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { renderHook, waitFor } from '@testing-library/react'; +import { wpPostsFixture } from '../../../../tests/fixtures'; +import { ROUTES } from '../../constants'; +import { useArticle } from './use-article'; + +describe('useArticle', () => { + beforeEach(() => { + /* Not sure why it is needed, but without it Jest was complaining with + * `Jest worker encountered 4 child process exceptions`... Maybe because of + * useSWR? */ + jest.useFakeTimers({ + doNotFake: ['queueMicrotask'], + }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + /* eslint-disable max-statements */ + it('fetch the requested article', async () => { + const { result } = renderHook(() => useArticle(wpPostsFixture[0].slug)); + + // Inaccurate assertions count because of waitFor... + //expect.assertions(8); + expect.hasAssertions(); + + expect(result.current.article).toBeUndefined(); + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(true); + expect(result.current.isValidating).toBe(true); + + jest.advanceTimersToNextTimer(); + + await waitFor(() => + expect(result.current.article?.slug).toBe( + `${ROUTES.ARTICLE}/${wpPostsFixture[0].slug}` + ) + ); + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.isValidating).toBe(false); + }); + /* eslint-enable max-statements */ +}); diff --git a/src/utils/hooks/use-article/use-article.ts b/src/utils/hooks/use-article/use-article.ts new file mode 100644 index 0000000..fbbc6bd --- /dev/null +++ b/src/utils/hooks/use-article/use-article.ts @@ -0,0 +1,28 @@ +import useSWR from 'swr'; +import { convertPostToArticle, fetchPost } from '../../../services/graphql'; +import type { Article, Maybe, WPPost } from '../../../types'; + +export type UseArticleReturn<T extends Maybe<WPPost>> = { + article: T extends undefined ? Maybe<Article> : Article; + isError: boolean; + isLoading: boolean; + isValidating: boolean; +}; + +export const useArticle = <T extends Maybe<WPPost>>( + slug: string, + fallback?: T +): UseArticleReturn<T> => { + const { data, error, isLoading, isValidating } = useSWR(slug, fetchPost, { + fallbackData: fallback, + }); + + if (error) console.error(error); + + return { + article: data ? convertPostToArticle(data) : undefined, + isError: !!error, + isLoading, + isValidating, + } as UseArticleReturn<T>; +}; diff --git a/src/utils/hooks/use-comments.ts b/src/utils/hooks/use-comments.ts deleted file mode 100644 index 94a2d7e..0000000 --- a/src/utils/hooks/use-comments.ts +++ /dev/null @@ -1,32 +0,0 @@ -import useSWR from 'swr'; -import { - type FetchCommentsListInput, - fetchCommentsList, - convertWPCommentToComment, - buildCommentsTree, -} from '../../services/graphql'; -import type { SingleComment } from '../../types'; - -export type UseCommentsConfig = FetchCommentsListInput & { - fallback?: SingleComment[]; -}; - -/** - * Retrieve the comments of a page/article. - * - * @param {string | number} contentId - A page/article id. - * @returns {SingleComment[]|undefined} - */ -export const useComments = ({ - fallback, - ...input -}: UseCommentsConfig): SingleComment[] | undefined => { - const { data } = useSWR(input, fetchCommentsList, {}); - - if (!data) return fallback; - - const comments = data.map(convertWPCommentToComment); - const commentsTree = buildCommentsTree(comments); - - return commentsTree; -}; diff --git a/src/utils/hooks/use-comments/index.ts b/src/utils/hooks/use-comments/index.ts new file mode 100644 index 0000000..8f69ffd --- /dev/null +++ b/src/utils/hooks/use-comments/index.ts @@ -0,0 +1 @@ +export * from './use-comments'; diff --git a/src/utils/hooks/use-comments/use-comments.test.ts b/src/utils/hooks/use-comments/use-comments.test.ts new file mode 100644 index 0000000..f05f9eb --- /dev/null +++ b/src/utils/hooks/use-comments/use-comments.test.ts @@ -0,0 +1,49 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useComments } from './use-comments'; + +describe('useComments', () => { + beforeEach(() => { + /* Not sure why it is needed, but without it Jest was complaining with + * `Jest worker encountered 4 child process exceptions`... Maybe because of + * useSWR? */ + jest.useFakeTimers({ + doNotFake: ['queueMicrotask'], + }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + /* eslint-disable max-statements */ + it('fetch the requested comments', async () => { + const { result } = renderHook(() => useComments({})); + + // Inaccurate assertions count because of waitFor... + //expect.assertions(8); + expect.hasAssertions(); + + expect(result.current.comments).toBeUndefined(); + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(true); + expect(result.current.isValidating).toBe(true); + + jest.advanceTimersToNextTimer(); + + await waitFor(() => expect(result.current.comments).toBeDefined()); + + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.isValidating).toBe(false); + }); + /* eslint-enable max-statements */ +}); diff --git a/src/utils/hooks/use-comments/use-comments.ts b/src/utils/hooks/use-comments/use-comments.ts new file mode 100644 index 0000000..cb967a5 --- /dev/null +++ b/src/utils/hooks/use-comments/use-comments.ts @@ -0,0 +1,42 @@ +import useSWR from 'swr'; +import { + type FetchCommentsListInput, + buildCommentsTree, + convertWPCommentToComment, + fetchCommentsList, +} from '../../../services/graphql'; +import type { Maybe, SingleComment, WPComment } from '../../../types'; + +export type UseCommentsReturn<T extends Maybe<WPComment[]>> = { + comments: T extends undefined ? Maybe<SingleComment[]> : SingleComment[]; + isError: boolean; + isLoading: boolean; + isValidating: boolean; +}; + +export type UseCommentsConfig<T extends Maybe<WPComment[]>> = + FetchCommentsListInput & { + fallback?: T; + }; + +export const useComments = <T extends Maybe<WPComment[]>>({ + fallback, + ...input +}: UseCommentsConfig<T>): UseCommentsReturn<T> => { + const { data, error, isLoading, isValidating } = useSWR( + input, + fetchCommentsList, + { fallbackData: fallback } + ); + + if (error) console.error(error); + + return { + comments: data + ? buildCommentsTree(data.map(convertWPCommentToComment)) + : undefined, + isError: !!error, + isLoading, + isValidating, + } as UseCommentsReturn<T>; +}; diff --git a/tests/cypress/e2e/pages/article.cy.ts b/tests/cypress/e2e/pages/article.cy.ts new file mode 100644 index 0000000..cf64015 --- /dev/null +++ b/tests/cypress/e2e/pages/article.cy.ts @@ -0,0 +1,49 @@ +import { ROUTES } from '../../../../src/utils/constants'; + +describe('Article', () => { + beforeEach(() => { + cy.visit(ROUTES.HOME); + cy.findAllByRole('link', { name: /^Consulter/i }).then(($articles) => + $articles[0].click() + ); + }); + + it('successfully loads', () => { + cy.findByRole('heading', { level: 1 }).should('exist'); + }); + + it('contains the article meta', () => { + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + cy.findAllByRole('term').should('have.length.at.least', 3); + + /* The accessible name is not recognized while it should be the `dt` text + * content */ + /* cy.findByRole('term', { name: 'Écrit par :' }).should('exist'); + cy.findByRole('term', { name: 'Publié le :' }).should('exist'); + cy.findByRole('term', { name: 'Temps de lecture :' }).should('exist'); */ + }); + + it('contains a breadcrumbs', () => { + cy.findByRole('navigation', { name: 'Fil d’Ariane' }).should('exist'); + }); + + it('contains a table of contents', () => { + cy.findByRole('heading', { level: 2, name: 'Table des matières' }).should( + 'exist' + ); + }); + + it('contains a sharing widget', () => { + cy.findByRole('heading', { level: 2, name: 'Partager' }).should('exist'); + }); + + it('contains a comments section', () => { + cy.findByRole('heading', { + level: 2, + name: 'Laisser un commentaire', + }).should('exist'); + cy.findByRole('form', { name: 'Formulaire des commentaires' }).should( + 'exist' + ); + }); +}); @@ -4225,7 +4225,7 @@ dependencies: "@types/node" "*" -"@types/hast@^3.0.0": +"@types/hast@^3.0.0", "@types/hast@^3.0.3": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.3.tgz#7f75e6b43bc3f90316046a287d9ad3888309f7e1" integrity sha512-2fYGlaDy/qyLlhidX42wAH0KBi2TCjKMH8CHmBXgRlJ3Y+OXTiqsPQ6IWarZKwF1JoUcAJdPogv1d4b0COTpmQ== @@ -8973,6 +8973,14 @@ hasown@^2.0.0: dependencies: function-bind "^1.1.2" +hast-util-classnames@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-classnames/-/hast-util-classnames-3.0.0.tgz#79d1e2c49fd0b2f4213f12048cb7a0439c351c8b" + integrity sha512-tI3JjoGDEBVorMAWK4jNRsfLMYmih1BUOG3VV36pH36njs1IEl7xkNrVTD2mD2yYHmQCa5R/fj61a8IAF4bRaQ== + dependencies: + "@types/hast" "^3.0.0" + space-separated-tokens "^2.0.0" + hast-util-from-html@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/hast-util-from-html/-/hast-util-from-html-2.0.1.tgz#9cd38ee81bf40b2607368b92a04b0905fa987488" |
