summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-05-10 17:38:07 +0200
committerArmand Philippot <git@armandphilippot.com>2022-05-13 15:46:01 +0200
commit9c8921db92d16b07ffc2a63ff3c80c4dcdd9ff9d (patch)
tree52e87fa8e758ec51cfbf7aa200982e0a6f5ab1ca
parent0d59a6d2995b4119865271ed1908ede0bb96497c (diff)
chore: add Project single pages
-rw-r--r--src/components/organisms/widgets/links-list-widget.tsx2
-rw-r--r--src/components/organisms/widgets/sharing.stories.tsx25
-rw-r--r--src/components/organisms/widgets/sharing.test.tsx10
-rw-r--r--src/components/organisms/widgets/sharing.tsx25
-rw-r--r--src/components/templates/page/page-layout.module.scss5
-rw-r--r--src/pages/projets/[slug].tsx249
-rw-r--r--src/pages/projets/index.tsx (renamed from src/pages/projets.tsx)37
-rw-r--r--src/ts/types/app.ts17
-rw-r--r--src/ts/types/swr.ts5
-rw-r--r--src/utils/helpers/projects.ts80
-rw-r--r--src/utils/helpers/slugify.ts18
-rw-r--r--src/utils/helpers/strings.ts29
-rw-r--r--src/utils/hooks/use-github-api.tsx30
-rw-r--r--src/utils/hooks/use-headings-tree.tsx2
14 files changed, 425 insertions, 109 deletions
diff --git a/src/components/organisms/widgets/links-list-widget.tsx b/src/components/organisms/widgets/links-list-widget.tsx
index 37a20fc..3f291e3 100644
--- a/src/components/organisms/widgets/links-list-widget.tsx
+++ b/src/components/organisms/widgets/links-list-widget.tsx
@@ -4,7 +4,7 @@ import List, {
type ListItem,
} from '@components/atoms/lists/list';
import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
-import { slugify } from '@utils/helpers/slugify';
+import { slugify } from '@utils/helpers/strings';
import { FC } from 'react';
import styles from './links-list-widget.module.scss';
diff --git a/src/components/organisms/widgets/sharing.stories.tsx b/src/components/organisms/widgets/sharing.stories.tsx
index c3c3488..47213b6 100644
--- a/src/components/organisms/widgets/sharing.stories.tsx
+++ b/src/components/organisms/widgets/sharing.stories.tsx
@@ -22,9 +22,13 @@ export default {
type: null,
},
description: 'Default widget state (expanded or collapsed).',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: true },
+ },
type: {
name: 'boolean',
- required: true,
+ required: false,
},
},
level: {
@@ -34,9 +38,13 @@ export default {
max: 6,
},
description: 'The heading level.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 2 },
+ },
type: {
name: 'number',
- required: true,
+ required: false,
},
},
media: {
@@ -49,16 +57,6 @@ export default {
required: true,
},
},
- title: {
- control: {
- type: 'text',
- },
- description: 'The widget title.',
- type: {
- name: 'string',
- required: true,
- },
- },
},
decorators: [
(Story) => (
@@ -78,14 +76,11 @@ const Template: ComponentStory<typeof SharingWidget> = (args) => (
*/
export const Sharing = Template.bind({});
Sharing.args = {
- expanded: true,
data: {
excerpt:
'Alias similique eius ducimus laudantium aspernatur. Est rem ut eum temporibus sit reprehenderit aut non molestias. Vel dolorem expedita labore quo inventore aliquid nihil nam. Possimus nobis enim quas corporis eos.',
title: 'Accusantium totam nostrum',
url: 'https://www.example.test',
},
- level: 2,
media: ['diaspora', 'facebook', 'linkedin', 'twitter', 'email'],
- title: 'Sharing',
};
diff --git a/src/components/organisms/widgets/sharing.test.tsx b/src/components/organisms/widgets/sharing.test.tsx
index 265dbe1..48da49e 100644
--- a/src/components/organisms/widgets/sharing.test.tsx
+++ b/src/components/organisms/widgets/sharing.test.tsx
@@ -9,15 +9,7 @@ const postData: SharingData = {
describe('Sharing', () => {
it('renders a sharing widget', () => {
- render(
- <Sharing
- data={postData}
- media={['facebook', 'twitter']}
- expanded={true}
- title="Sharing"
- level={2}
- />
- );
+ render(<Sharing data={postData} media={['facebook', 'twitter']} />);
expect(
screen.getByRole('link', { name: 'Share on facebook' })
).toBeInTheDocument();
diff --git a/src/components/organisms/widgets/sharing.tsx b/src/components/organisms/widgets/sharing.tsx
index 05a3f73..85dadb0 100644
--- a/src/components/organisms/widgets/sharing.tsx
+++ b/src/components/organisms/widgets/sharing.tsx
@@ -21,12 +21,20 @@ export type SharingData = {
url: string;
};
-export type SharingProps = Pick<WidgetProps, 'expanded' | 'level' | 'title'> & {
+export type SharingProps = {
/**
* The page data to share.
*/
data: SharingData;
/**
+ * The widget default state.
+ */
+ expanded?: WidgetProps['expanded'];
+ /**
+ * The HTML heading level.
+ */
+ level?: WidgetProps['level'];
+ /**
* A list of active and ordered sharing medium.
*/
media: SharingMedium[];
@@ -37,8 +45,19 @@ export type SharingProps = Pick<WidgetProps, 'expanded' | 'level' | 'title'> & {
*
* Render a list of sharing links inside a widget.
*/
-const Sharing: FC<SharingProps> = ({ data, media, ...props }) => {
+const Sharing: FC<SharingProps> = ({
+ data,
+ media,
+ expanded = true,
+ level = 2,
+ ...props
+}) => {
const intl = useIntl();
+ const widgetTitle = intl.formatMessage({
+ defaultMessage: 'Share',
+ id: 'q3U6uI',
+ description: 'Sharing: widget title',
+ });
/**
* Build the Diaspora sharing url with provided data.
@@ -181,7 +200,7 @@ const Sharing: FC<SharingProps> = ({ data, media, ...props }) => {
};
return (
- <Widget {...props}>
+ <Widget expanded={expanded} level={level} title={widgetTitle} {...props}>
<ul className={styles.list}>{getItems()}</ul>
</Widget>
);
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
index d5a1a2b..7602492 100644
--- a/src/components/templates/page/page-layout.module.scss
+++ b/src/components/templates/page/page-layout.module.scss
@@ -35,6 +35,11 @@
.body {
grid-column: 2;
+
+ > * + * {
+ margin-top: var(--spacing-sm);
+ margin-bottom: var(--spacing-sm);
+ }
}
.sidebar {
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
new file mode 100644
index 0000000..711a5cd
--- /dev/null
+++ b/src/pages/projets/[slug].tsx
@@ -0,0 +1,249 @@
+import Link from '@components/atoms/links/link';
+import SocialLink, { SocialWebsite } from '@components/atoms/links/social-link';
+import Spinner from '@components/atoms/loaders/spinner';
+import ResponsiveImage from '@components/molecules/images/responsive-image';
+import Code from '@components/molecules/layout/code';
+import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import Gallery from '@components/organisms/images/gallery';
+import Overview, { OverviewMeta } from '@components/organisms/layout/overview';
+import Sharing from '@components/organisms/widgets/sharing';
+import PageLayout, {
+ PageLayoutProps,
+} from '@components/templates/page/page-layout';
+import { ProjectPreview, Repos } from '@ts/types/app';
+import { loadTranslation, Messages } from '@utils/helpers/i18n';
+import { getProjectData, getProjectFilenames } from '@utils/helpers/projects';
+import { capitalize } from '@utils/helpers/strings';
+import useGithubApi, { RepoData } from '@utils/hooks/use-github-api';
+import useSettings from '@utils/hooks/use-settings';
+import { MDXComponents, NestedMDXComponents } from 'mdx/types';
+import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import Script from 'next/script';
+import { ComponentType } from 'react';
+import { useIntl } from 'react-intl';
+import { Article, Graph, WebPage } from 'schema-dts';
+
+type ProjectPageProps = {
+ project: ProjectPreview;
+ translation: Messages;
+};
+
+/**
+ * Project page.
+ */
+const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
+ const { id, intro, meta, title } = project;
+ const { cover, dates, license, repos, seo, technologies } = meta;
+ const intl = useIntl();
+ const homeLabel = intl.formatMessage({
+ defaultMessage: 'Home',
+ description: 'Breadcrumb: home label',
+ id: 'j5k9Fe',
+ });
+ const projectsLabel = intl.formatMessage({
+ defaultMessage: 'Projects',
+ description: 'Breadcrumb: projects label',
+ id: '28GZdv',
+ });
+ const breadcrumb: BreadcrumbItem[] = [
+ { id: 'home', name: homeLabel, url: '/' },
+ { id: 'projects', name: projectsLabel, url: '/projets' },
+ { id: 'project', name: title, url: `/projets/${id}` },
+ ];
+
+ const ProjectContent: ComponentType<MDXComponents> =
+ require(`../../content/projects/${id}.mdx`).default;
+
+ const components: NestedMDXComponents = {
+ Code: (props) => <Code {...props} />,
+ Gallery: (props) => <Gallery {...props} />,
+ Image: (props) => <ResponsiveImage {...props} />,
+ Link: (props) => <Link {...props} />,
+ pre: ({ children }) => <Code {...children.props} />,
+ };
+
+ const { website } = useSettings();
+ const { asPath } = useRouter();
+ const pageUrl = `${website.url}${asPath}`;
+ const pagePublicationDate = new Date(dates.publication);
+ const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
+
+ const headerMeta: PageLayoutProps['headerMeta'] = {
+ publication: { date: dates.publication },
+ update: dates.update ? { date: dates.update } : undefined,
+ };
+
+ /**
+ * Retrieve the repositories links.
+ *
+ * @param {Repos} repos - A repositories object.
+ * @returns {JSX.Element[]} - An array of SocialLink.
+ */
+ const getReposLinks = (repositories: Repos): JSX.Element[] => {
+ const links = [];
+
+ for (const [name, url] of Object.entries(repositories)) {
+ const socialWebsite = capitalize(name) as SocialWebsite;
+ const socialUrl = `https://${name}.com/${url}`;
+
+ links.push(<SocialLink name={socialWebsite} url={socialUrl} />);
+ }
+
+ return links;
+ };
+
+ const { isError, isLoading, data } = useGithubApi(meta.repos!.github!);
+
+ const getGithubData = (key: keyof RepoData) => {
+ if (isError) return 'Error';
+ if (isLoading || !data) return <Spinner />;
+
+ switch (key) {
+ case 'created_at':
+ return data.created_at;
+ case 'updated_at':
+ return data.updated_at;
+ case 'stargazers_count':
+ const stars = intl.formatMessage(
+ {
+ defaultMessage:
+ '{starsCount, plural, =0 {No stars on Github} one {# star on Github} other {# stars on Github}}',
+ id: 'Gnf1Si',
+ description: 'Projets: Github stars count',
+ },
+ { starsCount: data.stargazers_count }
+ );
+ return (
+ <>
+ ⭐&nbsp;
+ <Link href={`https://github.com/${repos!.github}/stargazers`}>
+ {stars}
+ </Link>
+ </>
+ );
+ }
+ };
+
+ const overviewData: OverviewMeta = {
+ creation: data && { date: getGithubData('created_at') as string },
+ update: data && { date: getGithubData('updated_at') as string },
+ license,
+ popularity: data && getGithubData('stargazers_count'),
+ repositories: repos ? getReposLinks(repos) : undefined,
+ technologies,
+ };
+
+ const webpageSchema: WebPage = {
+ '@id': `${pageUrl}`,
+ '@type': 'WebPage',
+ breadcrumb: { '@id': `${website.url}/#breadcrumb` },
+ name: seo.title,
+ description: seo.description,
+ inLanguage: website.locales.default,
+ reviewedBy: { '@id': `${website.url}/#branding` },
+ url: `${website.url}`,
+ isPartOf: {
+ '@id': `${website.url}`,
+ },
+ };
+
+ const articleSchema: Article = {
+ '@id': `${website.url}/project`,
+ '@type': 'Article',
+ name: title,
+ description: intro,
+ author: { '@id': `${website.url}/#branding` },
+ copyrightYear: pagePublicationDate.getFullYear(),
+ creator: { '@id': `${website.url}/#branding` },
+ dateCreated: pagePublicationDate.toISOString(),
+ dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
+ datePublished: pagePublicationDate.toISOString(),
+ editor: { '@id': `${website.url}/#branding` },
+ headline: title,
+ thumbnailUrl: `/projects/${id}.jpg`,
+ image: `/projects/${id}.jpg`,
+ inLanguage: website.locales.default,
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+ mainEntityOfPage: { '@id': `${pageUrl}` },
+ };
+
+ const schemaJsonLd: Graph = {
+ '@context': 'https://schema.org',
+ '@graph': [webpageSchema, articleSchema],
+ };
+
+ return (
+ <>
+ <Head>
+ <title>{`${seo.title} - ${website.name}`}</title>
+ <meta name="description" content={seo.description} />
+ <meta property="og:url" content={`${pageUrl}`} />
+ <meta property="og:type" content="article" />
+ <meta property="og:title" content={title} />
+ <meta property="og:description" content={intro} />
+ </Head>
+ <Script
+ id="schema-project"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+ />
+ <PageLayout
+ title={title}
+ intro={intro}
+ breadcrumb={breadcrumb}
+ headerMeta={headerMeta}
+ withToC={true}
+ widgets={[
+ <Sharing
+ key="sharing-widget"
+ data={{ excerpt: intro, title, url: pageUrl }}
+ media={[
+ 'diaspora',
+ 'email',
+ 'facebook',
+ 'journal-du-hacker',
+ 'linkedin',
+ 'twitter',
+ ]}
+ />,
+ ]}
+ >
+ <Overview cover={cover} meta={overviewData} />
+ <ProjectContent components={components} />
+ </PageLayout>
+ </>
+ );
+};
+
+export const getStaticProps: GetStaticProps = async ({ locale, params }) => {
+ const translation = await loadTranslation(locale);
+ const { slug } = params!;
+ const project = await getProjectData(slug as string);
+
+ return {
+ props: {
+ project,
+ translation,
+ },
+ };
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+ const filenames = getProjectFilenames();
+ const paths = filenames.map((filename) => {
+ return {
+ params: {
+ slug: filename,
+ },
+ };
+ });
+
+ return {
+ paths,
+ fallback: false,
+ };
+};
+
+export default ProjectPage;
diff --git a/src/pages/projets.tsx b/src/pages/projets/index.tsx
index 996af74..a6858c8 100644
--- a/src/pages/projets.tsx
+++ b/src/pages/projets/index.tsx
@@ -41,21 +41,12 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
const items: CardsListItem[] = projects.map(
({ id, meta: projectMeta, slug, title: projectTitle }) => {
- const { cover, tagline, ...remainingMeta } = projectMeta;
- const formattedMeta: CardsListItem['meta'] = remainingMeta.technologies
- ? [
- {
- id: 'technologies',
- term: 'Technologies',
- value: remainingMeta.technologies,
- },
- ]
- : undefined;
+ const { cover, tagline, technologies } = projectMeta;
return {
cover,
id: id as string,
- meta: formattedMeta,
+ meta: { technologies: technologies },
tagline,
title: projectTitle,
url: `/projets/${slug}`,
@@ -71,7 +62,7 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
const { asPath } = useRouter();
const pageUrl = `${website.url}${asPath}`;
const pagePublicationDate = new Date(dates.publication);
- const pageUpdateDate = new Date(dates.update);
+ const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
const webpageSchema: WebPage = {
'@id': `${pageUrl}`,
@@ -97,7 +88,7 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
copyrightYear: pagePublicationDate.getFullYear(),
creator: { '@id': `${website.url}/#branding` },
dateCreated: pagePublicationDate.toISOString(),
- dateModified: pageUpdateDate.toISOString(),
+ dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
datePublished: pagePublicationDate.toISOString(),
editor: { '@id': `${website.url}/#branding` },
headline: meta.title,
@@ -112,13 +103,9 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
};
return (
- <PageLayout
- title={title}
- intro={<PageContent components={components} />}
- breadcrumb={breadcrumb}
- >
+ <>
<Head>
- <title>{seo.title}</title>
+ <title>{`${seo.title} - ${website.name}`}</title>
<meta name="description" content={seo.description} />
<meta property="og:url" content={`${pageUrl}`} />
<meta property="og:type" content="article" />
@@ -130,8 +117,14 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <CardsList items={items} titleLevel={2} className={styles.list} />
- </PageLayout>
+ <PageLayout
+ title={title}
+ intro={<PageContent components={components} />}
+ breadcrumb={breadcrumb}
+ >
+ <CardsList items={items} titleLevel={2} className={styles.list} />
+ </PageLayout>
+ </>
);
};
@@ -141,7 +134,7 @@ export const getStaticProps: GetStaticProps = async ({ locale }) => {
return {
props: {
- projects,
+ projects: JSON.parse(JSON.stringify(projects)),
translation,
},
};
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index 87ab042..f354118 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -29,7 +29,7 @@ export type Comment = {
export type Dates = {
publication: string;
- update: string;
+ update?: string;
};
export type Image = {
@@ -58,23 +58,23 @@ export type Meta<T extends PageKind> = {
commentsCount?: T extends 'article' ? number : never;
cover?: Image;
dates: Dates;
- license?: T extends 'projects' ? string : never;
- readingTime: number;
- repos?: T extends 'projects' ? Repos : never;
+ license?: T extends 'project' ? string : never;
+ readingTime?: number;
+ repos?: T extends 'project' ? Repos : never;
seo: SEO;
- tagline?: T extends 'projects' ? string : never;
- technologies?: T extends 'projects' ? string[] : never;
+ tagline?: T extends 'project' ? string : never;
+ technologies?: T extends 'project' ? string[] : never;
thematics?: T extends 'article' | 'topic' ? PageLink[] : never;
topics?: T extends 'article' | 'thematic' ? PageLink[] : never;
website?: T extends 'topic' ? string : never;
- wordsCount: number;
+ wordsCount?: number;
};
export type Page<T extends PageKind> = {
content: string;
id: number | string;
intro: string;
- meta?: Meta<T>;
+ meta: Meta<T>;
slug: string;
title: string;
};
@@ -89,6 +89,7 @@ export type Article = Page<'article'>;
export type ArticleCard = Pick<Article, 'id' | 'slug' | 'title'> &
Pick<Meta<'article'>, 'cover' | 'dates'>;
export type Project = Page<'project'>;
+export type ProjectPreview = Omit<Page<'project'>, 'content'>;
export type ProjectCard = Pick<Page<'project'>, 'id' | 'slug' | 'title'> & {
meta: Pick<Meta<'project'>, 'cover' | 'dates' | 'tagline' | 'technologies'>;
};
diff --git a/src/ts/types/swr.ts b/src/ts/types/swr.ts
new file mode 100644
index 0000000..4da6b2c
--- /dev/null
+++ b/src/ts/types/swr.ts
@@ -0,0 +1,5 @@
+export type SWRResult<T> = {
+ data?: T;
+ isLoading: boolean;
+ isError: boolean;
+};
diff --git a/src/utils/helpers/projects.ts b/src/utils/helpers/projects.ts
index 02e0e02..a0f0c04 100644
--- a/src/utils/helpers/projects.ts
+++ b/src/utils/helpers/projects.ts
@@ -1,4 +1,4 @@
-import { ProjectCard } from '@ts/types/app';
+import { ProjectCard, ProjectPreview } from '@ts/types/app';
import { MDXProjectMeta } from '@ts/types/mdx';
import { readdirSync } from 'fs';
import path from 'path';
@@ -16,45 +16,61 @@ export const getProjectFilenames = (): string[] => {
};
/**
- * Retrieve project's data by filename.
+ * Retrieve the data of a project by filename.
+ *
+ * @param {string} filename - The project filename.
+ * @returns {Promise<ProjectPreview>}
+ */
+export const getProjectData = async (
+ filename: string
+): Promise<ProjectPreview> => {
+ try {
+ const {
+ meta,
+ }: {
+ meta: MDXProjectMeta;
+ } = await import(`../../content/projects/${filename}.mdx`);
+
+ const { dates, intro, title, ...projectMeta } = meta;
+ const { publication, update } = dates;
+ const cover = await import(`../../../public/projects/${filename}.jpg`);
+
+ return {
+ id: filename,
+ intro,
+ meta: {
+ ...projectMeta,
+ dates: { publication, update },
+ // Dynamic import source does not work so I use it only to get sizes
+ cover: {
+ ...cover.default,
+ alt: `${title} image`,
+ src: `/projects/${filename}.jpg`,
+ },
+ },
+ slug: filename,
+ title: title,
+ };
+ } catch (err) {
+ console.error(err);
+ throw err;
+ }
+};
+
+/**
+ * Retrieve all the projects data using filenames.
*
* @param {string[]} filenames - The filenames without extension.
- * @returns {Promise<ProjectCard[]>} - The project data.
+ * @returns {Promise<ProjectCard[]>} - An array of projects data.
*/
export const getProjectsData = async (
filenames: string[]
): Promise<ProjectCard[]> => {
return Promise.all(
filenames.map(async (filename) => {
- try {
- const {
- meta,
- }: {
- meta: MDXProjectMeta;
- } = await import(`../../content/projects/${filename}.mdx`);
-
- const { intro: _intro, title, ...projectMeta } = meta;
-
- const cover = await import(`../../../public/projects/${filename}.jpg`);
-
- return {
- id: filename,
- meta: {
- ...projectMeta,
- // Dynamic import source does not work so I use it only to get sizes
- cover: {
- ...cover.default,
- alt: `${title} image`,
- src: `/projects/${filename}.jpg`,
- },
- },
- slug: filename,
- title: title,
- };
- } catch (err) {
- console.error(err);
- throw err;
- }
+ const { id, meta, slug, title } = await getProjectData(filename);
+ const { cover, dates, tagline, technologies } = meta;
+ return { id, meta: { cover, dates, tagline, technologies }, slug, title };
})
);
};
diff --git a/src/utils/helpers/slugify.ts b/src/utils/helpers/slugify.ts
deleted file mode 100644
index 55ff583..0000000
--- a/src/utils/helpers/slugify.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * Convert a text into a slug or id.
- * https://gist.github.com/codeguy/6684588#gistcomment-3332719
- *
- * @param {string} text Text to slugify.
- */
-export const slugify = (text: string) => {
- return text
- .toString()
- .normalize('NFD')
- .replace(/[\u0300-\u036f]/g, '')
- .toLowerCase()
- .trim()
- .replace(/\s+/g, '-')
- .replace(/[^\w\-]+/g, '-')
- .replace(/\-\-+/g, '-')
- .replace(/(^-)|(-$)/g, '');
-};
diff --git a/src/utils/helpers/strings.ts b/src/utils/helpers/strings.ts
new file mode 100644
index 0000000..5d90161
--- /dev/null
+++ b/src/utils/helpers/strings.ts
@@ -0,0 +1,29 @@
+/**
+ * Convert a text into a slug or id.
+ * https://gist.github.com/codeguy/6684588#gistcomment-3332719
+ *
+ * @param {string} text - A text to slugify.
+ * @returns {string} The slug.
+ */
+export const slugify = (text: string): string => {
+ return text
+ .toString()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase()
+ .trim()
+ .replace(/\s+/g, '-')
+ .replace(/[^\w\-]+/g, '-')
+ .replace(/\-\-+/g, '-')
+ .replace(/(^-)|(-$)/g, '');
+};
+
+/**
+ * Capitalize the first letter of a string.
+ *
+ * @param {string} text - A text to capitalize.
+ * @returns {string} The capitalized text.
+ */
+export const capitalize = (text: string): string => {
+ return text.replace(/^\w/, (firstLetter) => firstLetter.toUpperCase());
+};
diff --git a/src/utils/hooks/use-github-api.tsx b/src/utils/hooks/use-github-api.tsx
new file mode 100644
index 0000000..edff974
--- /dev/null
+++ b/src/utils/hooks/use-github-api.tsx
@@ -0,0 +1,30 @@
+import { SWRResult } from '@ts/types/swr';
+import useSWR, { Fetcher } from 'swr';
+
+export type RepoData = {
+ created_at: string;
+ updated_at: string;
+ stargazers_count: number;
+};
+
+const fetcher: Fetcher<RepoData, string> = (...args) =>
+ fetch(...args).then((res) => res.json());
+
+/**
+ * Retrieve data from Github API.
+ *
+ * @param repo - The Github repo (`owner/repo-name`).
+ * @returns The repository data.
+ */
+const useGithubApi = (repo: string): SWRResult<RepoData> => {
+ const apiUrl = repo ? `https://api.github.com/repos/${repo}` : null;
+ const { data, error } = useSWR<RepoData>(apiUrl, fetcher);
+
+ return {
+ data,
+ isLoading: !error && !data,
+ isError: error,
+ };
+};
+
+export default useGithubApi;
diff --git a/src/utils/hooks/use-headings-tree.tsx b/src/utils/hooks/use-headings-tree.tsx
index 5506e8b..4646b4a 100644
--- a/src/utils/hooks/use-headings-tree.tsx
+++ b/src/utils/hooks/use-headings-tree.tsx
@@ -1,4 +1,4 @@
-import { slugify } from '@utils/helpers/slugify';
+import { slugify } from '@utils/helpers/strings';
import { useCallback, useEffect, useMemo, useState } from 'react';
export type Heading = {