feat: implement initial strapi integration

This commit is contained in:
Hossein Mehrabi 2024-01-18 00:57:05 +03:30
parent e4ef86af4d
commit 7189c9982f
No known key found for this signature in database
GPG Key ID: 45C04964191AFAA1
47 changed files with 5831 additions and 208 deletions

View File

@ -2,10 +2,9 @@ module.exports = {
client: { client: {
includes: ['src/**/*.{ts,tsx}'], includes: ['src/**/*.{ts,tsx}'],
service: { service: {
name: 'unbody-graphql', name: 'strapi-graphql',
localSchemaFile: [ localSchemaFile: [
'./src/lib/unbody/unbody.graphql', './src/lib/strapi/strapi.graphql',
'./src/lib/unbody/unbody.extend.graphql',
], // how to configure to multiple schemas? ], // how to configure to multiple schemas?
}, },
}, },

View File

@ -1,8 +1,7 @@
import { CodegenConfig } from '@graphql-codegen/cli' import { CodegenConfig } from '@graphql-codegen/cli'
const graphqlEndpoint = 'https://graphql.unbody.io' const graphqlEndpoint = process.env.STRAPI_GRAPHQL_URL || ''
const projectId = process.env.UNBODY_PROJECT_ID || '' const token = process.env.STRAPI_API_KEY || ''
const authorization = process.env.UNBODY_API_KEY || ''
const config: CodegenConfig = { const config: CodegenConfig = {
overwrite: true, overwrite: true,
@ -10,21 +9,19 @@ const config: CodegenConfig = {
{ {
[graphqlEndpoint]: { [graphqlEndpoint]: {
headers: { headers: {
authorization, authorization: token,
'x-project-id': projectId,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
}, },
}, },
}, },
'src/lib/unbody/unbody.extend.graphql',
], ],
documents: ['src/**/*.{ts,tsx}'], documents: ['src/services/strapi/*.{ts,tsx}'],
generates: { generates: {
'src/lib/unbody/unbody.graphql': { 'src/lib/strapi/strapi.graphql': {
plugins: ['schema-ast'], plugins: ['schema-ast'],
}, },
'src/lib/unbody/unbody.generated.ts': { 'src/lib/strapi/strapi.generated.ts': {
plugins: ['typescript', 'typescript-operations', 'typed-document-node'], plugins: ['typescript', 'typescript-operations', 'typed-document-node'],
presetConfig: { presetConfig: {
fragmentMasking: false, fragmentMasking: false,

View File

@ -3,12 +3,11 @@ const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
images: { images: {
domains: [ domains: [
'images.cdn.unbody.io', 'localhost',
'127.0.0.1',
'image.simplecastcdn.com', 'image.simplecastcdn.com',
'img.youtube.com', 'img.youtube.com',
], ],
// loader: 'imgix',
// path: 'https://images.cdn.unbody.io',
}, },
} }

View File

@ -51,6 +51,7 @@
"next": "13.3.0", "next": "13.3.0",
"next-query-params": "^4.2.3", "next-query-params": "^4.2.3",
"nextjs-progressbar": "^0.0.16", "nextjs-progressbar": "^0.0.16",
"node-html-parser": "^6.1.12",
"odoo-await": "^3.4.1", "odoo-await": "^3.4.1",
"react": "18.2.0", "react": "18.2.0",
"react-blurhash": "^0.3.0", "react-blurhash": "^0.3.0",
@ -59,6 +60,7 @@
"react-player": "^2.12.0", "react-player": "^2.12.0",
"react-quick-pinch-zoom": "^4.9.0", "react-quick-pinch-zoom": "^4.9.0",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"slugify": "^1.6.6",
"typescript": "5.0.4", "typescript": "5.0.4",
"use-query-params": "^2.2.1", "use-query-params": "^2.2.1",
"yup": "^1.3.2" "yup": "^1.3.2"

View File

@ -39,6 +39,17 @@ export const RenderArticleBlock = ({
case 'h6': { case 'h6': {
return <ArticleHeading block={block} /> return <ArticleHeading block={block} />
} }
case 'blockquote':
return (
<Quote mode="indented-line" genericFontFamily="serif">
<Paragraph
variant="body1"
dangerouslySetInnerHTML={{
__html: extractInnerHtml(block.html),
}}
/>
</Quote>
)
case 'p': { case 'p': {
const isIframe = block.embed && block.labels.includes('embed') const isIframe = block.embed && block.labels.includes('embed')
if (block.embed && isIframe) { if (block.embed && isIframe) {
@ -56,22 +67,6 @@ export const RenderArticleBlock = ({
) )
} }
if (
block.classNames.includes('subtitle') &&
block.classNames.includes('u-with-margin-left')
) {
return (
<Quote mode="indented-line" genericFontFamily="serif">
<Paragraph
variant="body1"
dangerouslySetInnerHTML={{
__html: extractInnerHtml(block.html),
}}
/>
</Quote>
)
}
return ( return (
<Paragraph <Paragraph
variant="body1" variant="body1"

View File

@ -12,7 +12,7 @@ type Props = {
export const ArticleImageBlockWrapper = ({ image, order }: Props) => { export const ArticleImageBlockWrapper = ({ image, order }: Props) => {
return ( return (
<Container id={`i-${order}`}> <Container id={`i-${order}`}>
<LightBox caption={image.alt}> <LightBox caption={image.caption || ''}>
<ResponsiveImage data={image} /> <ResponsiveImage data={image} />
</LightBox> </LightBox>
</Container> </Container>

View File

@ -15,6 +15,7 @@ import ArticleSummary from './Article.Summary'
export type ArticleHeaderProps = LPE.Article.Data export type ArticleHeaderProps = LPE.Article.Data
const ArticleHeader = ({ const ArticleHeader = ({
title,
summary, summary,
subtitle, subtitle,
authors, authors,
@ -33,19 +34,15 @@ const ArticleHeader = ({
date={modifiedAt ? new Date(modifiedAt) : null} date={modifiedAt ? new Date(modifiedAt) : null}
readingLength={readingTime} readingLength={readingTime}
/> />
<ArticleTitle <span
block={ id="title-anchor"
content.find((block) => ref={(ref) => {
block.labels.includes(LPE.Article.ContentBlockLabels.Title), headingElementsRef.current['h-0'] = ref as HTMLHeadingElement
) as LPE.Article.TextBlock
}
typographyProps={{
variant: 'h1',
genericFontFamily: 'serif',
component: 'h1',
}} }}
headingElementsRef={headingElementsRef} ></span>
/> <Typography variant="h1" component="h1" genericFontFamily="serif">
{title}
</Typography>
{subtitle && ( {subtitle && (
<ArticleSubtitle <ArticleSubtitle
variant="body1" variant="body1"
@ -55,7 +52,10 @@ const ArticleHeader = ({
{subtitle} {subtitle}
</ArticleSubtitle> </ArticleSubtitle>
)} )}
<TagsAndSocial tags={tags} className={'articleTags'} /> <TagsAndSocial
tags={tags.map((tag) => tag.name)}
className={'articleTags'}
/>
<AuthorsContainer> <AuthorsContainer>
<Authors authors={authors} /> <Authors authors={authors} />
</AuthorsContainer> </AuthorsContainer>
@ -65,7 +65,9 @@ const ArticleHeader = ({
order={ArticleBlocksOrders.cover} order={ArticleBlocksOrders.cover}
/> />
)} )}
<ArticleSummary summary={summary} showLabel={false} /> {summary && summary.length > 0 && (
<ArticleSummary summary={summary} showLabel={false} />
)}
</ArticleHeaderContainer> </ArticleHeaderContainer>
) )
} }

View File

@ -4,6 +4,8 @@ import { LPE } from '../../types/lpe.types'
import EpisodeBlocks from './Episode.Blocks' import EpisodeBlocks from './Episode.Blocks'
const EpisodeTranscript = ({ episode }: { episode: LPE.Podcast.Document }) => { const EpisodeTranscript = ({ episode }: { episode: LPE.Podcast.Document }) => {
if (episode.content.length === 0) return <></>
return ( return (
<Container> <Container>
<Title variant={'h5'} genericFontFamily={'serif'}> <Title variant={'h5'} genericFontFamily={'serif'}>

View File

@ -24,9 +24,8 @@ const EpisodeCredits = ({
component="p" component="p"
variant="label1" variant="label1"
id={credit.id.replace('#', '')} id={credit.id.replace('#', '')}
> dangerouslySetInnerHTML={{ __html: credit.html }}
{credit.text} />
</Typography>
</Credit> </Credit>
))} ))}
</Credits> </Credits>

View File

@ -26,7 +26,7 @@ const EpisodeFooter = ({ episode, relatedEpisodes }: Props) => {
<EpisodeFooterContainer> <EpisodeFooterContainer>
{!!episode?.credits && <EpisodeCredits credits={episode.credits} />} {!!episode?.credits && <EpisodeCredits credits={episode.credits} />}
{!!footnotes && <EpisodeFootnotes footnotes={footnotes} />} {!!footnotes && <EpisodeFootnotes footnotes={footnotes} />}
{!!relatedEpisodes && ( {!!relatedEpisodes && relatedEpisodes.length > 0 && (
<RelatedEpisodes <RelatedEpisodes
podcastSlug={episode.show?.slug as string} podcastSlug={episode.show?.slug as string}
relatedEpisodes={relatedEpisodes} relatedEpisodes={relatedEpisodes}

View File

@ -54,7 +54,7 @@ const EpisodeHeader = ({
</Show> </Show>
</CustomLink> </CustomLink>
)} )}
<TagsAndSocial tags={tags} /> <TagsAndSocial tags={tags.map((tag) => tag.name)} />
{channels && <EpisodeChannels channels={channels} />} {channels && <EpisodeChannels channels={channels} />}
{description && ( {description && (
<ArticleSummary summary={description} showLabel={false} /> <ArticleSummary summary={description} showLabel={false} />

View File

@ -5,7 +5,7 @@ export type EpisodeState = {
title: string title: string
podcast: string podcast: string
url: string url: string
coverImage: LPE.Post.ImageBlock | null coverImage: LPE.Image.Document | null
path: string path: string
} }

View File

@ -156,7 +156,7 @@ PostCard.toData = (post: LPE.Post.Document, shows: LPE.Podcast.Show[] = []) => {
authors: post.type === 'article' ? post.authors : [], authors: post.type === 'article' ? post.authors : [],
coverImage: post.coverImage, coverImage: post.coverImage,
subtitle: (post.type === 'article' && post.subtitle) || '', subtitle: (post.type === 'article' && post.subtitle) || '',
tags: post.tags, tags: post.tags.map((tag) => tag.name),
...(post.type === 'podcast' && show ...(post.type === 'podcast' && show
? { ? {
podcastShowDetails: { podcastShowDetails: {

View File

@ -67,7 +67,7 @@ export const PostsList = (props: Props) => {
title: post.title, title: post.title,
subtitle: post.subtitle, subtitle: post.subtitle,
coverImage: post.coverImage, coverImage: post.coverImage,
tags: post.tags, tags: post.tags.map((tag) => tag.name),
}} }}
contentType={PostTypes.Article} contentType={PostTypes.Article}
/> />

View File

@ -41,6 +41,7 @@ export const HomePage: React.FC<HomePageProps> = ({
<Container> <Container>
<div> <div>
<PostsGrid <PostsGrid
shows={shows}
posts={highlighted.slice(0, 1)} posts={highlighted.slice(0, 1)}
pattern={[{ cols: 1, size: 'large' }]} pattern={[{ cols: 1, size: 'large' }]}
breakpoints={[ breakpoints={[
@ -56,6 +57,7 @@ export const HomePage: React.FC<HomePageProps> = ({
/> />
<Section title="Latest posts"> <Section title="Latest posts">
<PostsGrid <PostsGrid
shows={shows}
pattern={[{ cols: 4, size: 'small' }]} pattern={[{ cols: 4, size: 'small' }]}
breakpoints={[ breakpoints={[
{ {
@ -103,11 +105,11 @@ export const HomePage: React.FC<HomePageProps> = ({
</div> </div>
<Grid xs={{ cols: 1 }} sm={{ cols: 4 }}> <Grid xs={{ cols: 1 }} sm={{ cols: 4 }}>
{tags.map((tag) => ( {tags.map((tag) => (
<GridItem key={tag.value} cols={1}> <GridItem key={tag.name} cols={1}>
<TagCard <TagCard
href={`/search?topic=${tag.value}`} href={`/search?topic=${tag.name}`}
name={formatTagText(tag.value)} name={formatTagText(tag.name)}
count={tag.count} count={tag.postsCount}
/> />
</GridItem> </GridItem>
))} ))}

View File

@ -18,22 +18,12 @@ export const StaticPage: React.FC<StaticPageProps> = ({
data: { page }, data: { page },
...props ...props
}) => { }) => {
const titleBlock = data.page.content.find((block) => {
return (
block.type === LPE.Post.ContentBlockTypes.Text &&
block.classNames &&
block.classNames.includes('title')
)
}) as LPE.Post.TextBlock | undefined
return ( return (
<Root {...props}> <Root {...props}>
<article> <article>
{titleBlock && ( <Typography variant={'h1'} genericFontFamily={'serif'}>
<Typography variant={'h1'} genericFontFamily={'serif'}> {page.title}
{titleBlock.text} </Typography>
</Typography>
)}
{data.page.content.map((block, idx) => ( {data.page.content.map((block, idx) => (
<RenderArticleBlock block={block} activeId={null} key={idx} /> <RenderArticleBlock block={block} activeId={null} key={idx} />
))} ))}

View File

@ -0,0 +1,87 @@
import { Helpers, Transformer } from './types'
export class TransformPipeline {
private transformers: Transformer[] = []
private helpers: Helpers
constructor(transformers: Transformer<any>[]) {
this.transformers = transformers
this.helpers = {
transformers: this,
}
}
getOne = ({
key,
classes,
objectType,
}: {
key?: string
classes?: string | string[]
objectType?: string
}) => {
let transformers = this.transformers
if (key) {
return transformers.find((doc) => doc.key === key)
}
return this.get({ classes, objectType })[0]
}
get = ({
classes: _classes,
objectType,
}: {
classes?: string | string[]
objectType?: string
}) => {
let transformers = this.transformers
if (objectType)
transformers = transformers.filter(
(dataType) => dataType.objectType === objectType,
)
const classes = !_classes
? []
: Array.isArray(_classes)
? _classes
: [_classes]
if (classes.length > 0)
transformers = transformers.filter((dataType) =>
classes.every((cls) => dataType.classes.includes(cls)),
)
return transformers
}
transform = async <O = any, T = any>(
pipeline: Transformer[],
data: T,
root?: any,
context?: any,
): Promise<O> => {
let obj = data
for (const dataType of pipeline) {
if (dataType.isMatch(this.helpers, obj, data, root, context)) {
obj = await dataType.transform(this.helpers, obj, data, root, context)
}
}
return obj as O | Promise<O>
}
transformMany = async <O = any, T = any>(
pipeline: Transformer[],
data: T[],
root?: any,
context?: any,
): Promise<O[]> => {
return Promise.all(
data.map((d) => this.transform<O, T>(pipeline, d, root, context)),
)
}
}

View File

@ -0,0 +1,27 @@
import { TransformPipeline } from './TransformPipeline'
export type Transformer<D = any, T = any, O = any, R = any, C = any> = {
key: string
classes: string[]
objectType: string
isMatch: (
helpers: Helpers,
object: D,
original: O,
root: R | undefined,
context: C,
) => boolean
transform: (
helpers: Helpers,
object: D,
original: O,
root: R | undefined,
context: C,
) => T | Promise<T>
}
export type Helpers = {
transformers: TransformPipeline
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ import { CustomNextPage, GetStaticPaths, GetStaticProps } from 'next'
import Error from 'next/error' import Error from 'next/error'
import SEO from '../components/SEO/SEO' import SEO from '../components/SEO/SEO'
import { StaticPage, StaticPageProps } from '../containers/StaticPage' import { StaticPage, StaticPageProps } from '../containers/StaticPage'
import unbodyApi from '../services/unbody/unbody.service' import { strapiApi } from '../services/strapi'
type PageProps = Partial<Pick<StaticPageProps, 'data'>> & { type PageProps = Partial<Pick<StaticPageProps, 'data'>> & {
error?: string error?: string
@ -37,7 +37,8 @@ const Page: CustomNextPage<PageProps> = ({
} }
export const getStaticPaths: GetStaticPaths = async () => { export const getStaticPaths: GetStaticPaths = async () => {
const { data } = await unbodyApi.getStaticPages() const { data } = await strapiApi.getStaticPages({})
return { return {
paths: data.map((page) => ({ paths: data.map((page) => ({
params: { params: {
@ -59,17 +60,18 @@ export const getStaticProps: GetStaticProps<PageProps> = async (ctx) => {
} }
} }
const { data, errors } = await unbodyApi.getStaticPage({ const { data, errors } = await strapiApi.getStaticPages({
parseContent: true,
slug: slug as string, slug: slug as string,
...(id ...(id
? { ? {
id, id,
includeDrafts: true, published: false,
} }
: {}), : {}),
}) })
if (!data) { if (!data || data.length === 0) {
if (errors && typeof errors === 'string' && errors.includes('not found')) { if (errors && typeof errors === 'string' && errors.includes('not found')) {
return { return {
notFound: true, notFound: true,
@ -93,7 +95,7 @@ export const getStaticProps: GetStaticProps<PageProps> = async (ctx) => {
return { return {
props: { props: {
data: { data: {
page: data, page: data[0],
}, },
}, },
notFound: false, notFound: false,

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next' import type { NextApiRequest, NextApiResponse } from 'next'
import unbodyApi from '../../../../../services/unbody/unbody.service' import { strapiApi } from '../../../../../services/strapi'
import { parseInt } from '../../../../../utils/data.utils' import { parseInt } from '../../../../../utils/data.utils'
export default async function handler( export default async function handler(
@ -10,7 +10,8 @@ export default async function handler(
query: { skip = 0, limit = 10, showSlug }, query: { skip = 0, limit = 10, showSlug },
} = req } = req
const response = await unbodyApi.getLatestEpisodes({ const response = await strapiApi.getLatestEpisodes({
highlighted: 'exclude',
skip: parseInt(skip, 0), skip: parseInt(skip, 0),
limit: parseInt(limit, 10), limit: parseInt(limit, 10),
showSlug: Array.isArray(showSlug) ? showSlug[0] : showSlug, showSlug: Array.isArray(showSlug) ? showSlug[0] : showSlug,

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next' import type { NextApiRequest, NextApiResponse } from 'next'
import unbodyApi from '../../../services/unbody/unbody.service' import { strapiApi } from '../../../services/strapi'
import { parseInt } from '../../../utils/data.utils' import { parseInt } from '../../../utils/data.utils'
export default async function handler( export default async function handler(
@ -10,9 +10,10 @@ export default async function handler(
query: { skip = 0, limit = 10 }, query: { skip = 0, limit = 10 },
} = req } = req
const response = await unbodyApi.getRecentPosts({ const response = await strapiApi.getRecentPosts({
skip: parseInt(skip, 0), skip: parseInt(skip as string, 0),
limit: parseInt(limit, 10), limit: parseInt(limit as string, 10),
highlighted: 'exclude',
}) })
res.status(200).json(response) res.status(200).json(response)

View File

@ -1,7 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next' import type { NextApiRequest, NextApiResponse } from 'next'
import unbodyApi from '../../../services/unbody/unbody.service'
import { LPE } from '../../../types/lpe.types' import { LPE } from '../../../types/lpe.types'
import { parseInt } from '../../../utils/data.utils'
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@ -61,31 +59,39 @@ export default async function handler(
} }
if (postTypes.length > 0) { if (postTypes.length > 0) {
const response = await unbodyApi.searchPosts({ // const response = await unbodyApi.searchPosts({
tags, // tags,
query: Array.isArray(q) ? q.join(' ').trim() : q.trim(), // query: Array.isArray(q) ? q.join(' ').trim() : q.trim(),
type: postTypes as LPE.PostType[], // type: postTypes as LPE.PostType[],
limit: parseInt(limit, 50), // limit: parseInt(limit, 50),
skip: parseInt(skip, 0), // skip: parseInt(skip, 0),
}) // })
const response = {
data: [],
}
result.posts.push(...response.data) result.posts.push(...response.data)
} }
if (blockTypes.length > 0) { if (blockTypes.length > 0) {
const response = await unbodyApi.searchPostBlocks({ // const response = await unbodyApi.searchPostBlocks({
tags, // tags,
query: Array.isArray(q) ? q.join(' ') : q, // query: Array.isArray(q) ? q.join(' ') : q,
postType: postTypes as LPE.PostType[], // postType: postTypes as LPE.PostType[],
type: blockTypes as LPE.Post.ContentBlockType[], // type: blockTypes as LPE.Post.ContentBlockType[],
method: 'hybrid', // method: 'hybrid',
limit: parseInt(limit, 50), // limit: parseInt(limit, 50),
skip: parseInt(skip, 0), // skip: parseInt(skip, 0),
}) // })
const response = {
data: [],
}
result.blocks.push(...response.data) result.blocks.push(...response.data)
} }

View File

@ -1,7 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next' import type { NextApiRequest, NextApiResponse } from 'next'
import unbodyApi from '../../../../services/unbody/unbody.service'
import { LPE } from '../../../../types/lpe.types' import { LPE } from '../../../../types/lpe.types'
import { parseInt } from '../../../../utils/data.utils'
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@ -27,26 +25,26 @@ export default async function handler(
blocks: [], blocks: [],
} }
const query: Parameters<(typeof unbodyApi)['searchPostBlocks']>[0] = { // const query: Parameters<(typeof unbodyApi)['searchPostBlocks']>[0] = {
tags, // tags,
type: ['text', 'image'], // type: ['text', 'image'],
postId: Array.isArray(id) ? id[0] : id, // postId: Array.isArray(id) ? id[0] : id,
query: Array.isArray(q) ? q.join(' ') : q, // query: Array.isArray(q) ? q.join(' ') : q,
method: 'nearText', // method: 'nearText',
certainty: 0.85, // certainty: 0.85,
limit: parseInt(limit, 50), // limit: parseInt(limit, 50),
skip: parseInt(skip, 0), // skip: parseInt(skip, 0),
} // }
result.blocks = await unbodyApi // result.blocks = await unbodyApi
.searchPostBlocks(query) // .searchPostBlocks(query)
.then((res) => res.data) // .then((res) => res.data)
if (result.blocks.length === 0) // if (result.blocks.length === 0)
result.blocks = await unbodyApi // result.blocks = await unbodyApi
.searchPostBlocks({ ...query, method: 'hybrid' }) // .searchPostBlocks({ ...query, method: 'hybrid' })
.then((res) => res.data) // .then((res) => res.data)
res.status(200).json({ res.status(200).json({
data: result, data: result,

View File

@ -1,7 +1,7 @@
import { SEO } from '@/components/SEO' import { SEO } from '@/components/SEO'
import ArticleContainer from '@/containers/ArticleContainer' import ArticleContainer from '@/containers/ArticleContainer'
import { GetStaticPropsContext } from 'next' import { GetStaticPropsContext } from 'next'
import unbodyApi from '../../services/unbody/unbody.service' import { strapiApi } from '../../services/strapi'
import { LPE } from '../../types/lpe.types' import { LPE } from '../../types/lpe.types'
type ArticleProps = { type ArticleProps = {
@ -24,7 +24,7 @@ const ArticlePage = ({ data, errors, why }: ArticleProps) => {
pagePath={`/article/${data.data.slug}`} pagePath={`/article/${data.data.slug}`}
date={data.data.createdAt} date={data.data.createdAt}
tags={[ tags={[
...data.data.tags, ...data.data.tags.map((tag) => tag.name),
...data.data.authors.map((author) => author.name), ...data.data.authors.map((author) => author.name),
]} ]}
contentType={LPE.PostTypes.Article} contentType={LPE.PostTypes.Article}
@ -35,17 +35,18 @@ const ArticlePage = ({ data, errors, why }: ArticleProps) => {
} }
export async function getStaticPaths() { export async function getStaticPaths() {
const { data: posts, errors } = await unbodyApi.getArticles({ const { data, errors } = await strapiApi.getPosts({
skip: 0, skip: 0,
limit: 50, limit: 50,
includeDrafts: false,
highlighted: 'include', highlighted: 'include',
parseContent: false,
published: true,
}) })
return { return {
paths: errors paths: errors
? [] ? []
: posts.map((post) => ({ params: { path: [post.slug] } })), : data.data.map((post) => ({ params: { path: [post.slug] } })),
fallback: true, fallback: true,
} }
} }
@ -68,13 +69,15 @@ export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
} }
} }
const { data, errors } = await unbodyApi.getArticle({ const { data: res, errors } = await strapiApi.getPosts({
parseContent: true, parseContent: true,
slug: slug as string, slug: slug as string,
...(id ? { id, includeDrafts: true } : {}), highlighted: 'include',
published: true,
...(id ? { id, published: false } : {}),
}) })
if (!data) { if (!res?.data || res.data.length === 0) {
return { return {
notFound: true, notFound: true,
props: { why: 'no article' }, props: { why: 'no article' },
@ -82,22 +85,26 @@ export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
} }
} }
const { data: relatedArticles } = await unbodyApi.getRelatedArticles({ const article = res.data[0]
id: data.id,
const { data: relatedArticles } = await strapiApi.getRelatedPosts({
id: article.id,
type: 'article',
}) })
const { data: articlesFromSameAuthors } = const { data: articlesFromSameAuthors } =
await unbodyApi.getArticlesFromSameAuthors({ await strapiApi.getPostsFromSameAuthors({
slug: slug as string, type: 'article',
authors: data.authors.map((author) => author.name), excludeId: article.id,
authors: article.authors.map((author) => author.id),
}) })
return { return {
props: { props: {
data: { data: {
data, data: article,
relatedArticles, relatedArticles: relatedArticles,
articlesFromSameAuthors, articlesFromSameAuthors: articlesFromSameAuthors.data,
}, },
error: JSON.stringify(errors), error: JSON.stringify(errors),
}, },

View File

@ -2,7 +2,7 @@ import { CustomNextPage, GetStaticProps } from 'next'
import SEO from '../components/SEO/SEO' import SEO from '../components/SEO/SEO'
import { HomePage, HomePageProps } from '../containers/HomePage' import { HomePage, HomePageProps } from '../containers/HomePage'
import { DefaultLayout } from '../layouts/DefaultLayout' import { DefaultLayout } from '../layouts/DefaultLayout'
import unbodyApi from '../services/unbody/unbody.service' import { strapiApi } from '../services/strapi'
type PageProps = Pick<HomePageProps, 'data'> type PageProps = Pick<HomePageProps, 'data'>
@ -27,19 +27,26 @@ Page.getLayout = function getLayout(page: React.ReactNode) {
} }
export const getStaticProps: GetStaticProps<PageProps> = async () => { export const getStaticProps: GetStaticProps<PageProps> = async () => {
const { data: tags = [] } = await unbodyApi.getTopics(true) // const { data: tags = [] } = await unbodyApi.getTopics(true)
const { data: highlighted } = await unbodyApi.getHighlightedPosts() // const { data: highlighted } = await unbodyApi.getHighlightedPosts()
const { data: latest } = await unbodyApi.getRecentPosts({ // const { data: latest } = await unbodyApi.getRecentPosts({
skip: 0, // skip: 0,
limit: 12, // limit: 15,
// })
const { data: latest } = await strapiApi.getRecentPosts({
highlighted: 'exclude',
limit: 15,
}) })
const { data: _shows = [] } = await unbodyApi.getPodcastShows({ const { data: highlighted } = await strapiApi.getHighlightedPosts()
const { data: _shows = [] } = await strapiApi.getPodcastShows({
populateEpisodes: true, populateEpisodes: true,
episodesLimit: 10, episodesLimit: 10,
}) })
const shows = [..._shows].sort((a, b) => (a.title > b.title ? -1 : 1)) const shows = [...(_shows ?? [])].sort((a, b) => (a.title > b.title ? -1 : 1))
const { data: tags = [] } = await strapiApi.getTopics()
return { return {
props: { props: {

View File

@ -3,7 +3,7 @@ import EpisodeContainer from '@/containers/EpisodeContainer'
import { GetStaticPropsContext } from 'next' import { GetStaticPropsContext } from 'next'
import { LPE } from '../../../types/lpe.types' import { LPE } from '../../../types/lpe.types'
import unbodyApi from '@/services/unbody/unbody.service' import { strapiApi } from '../../../services/strapi'
import { getPostLink } from '../../../utils/route.utils' import { getPostLink } from '../../../utils/route.utils'
type EpisodeProps = { type EpisodeProps = {
@ -29,7 +29,7 @@ const EpisodePage = ({ episode, relatedEpisodes, errors }: EpisodeProps) => {
postSlug: episode.slug as string, postSlug: episode.slug as string,
})} })}
tags={[ tags={[
...episode.tags, ...episode.tags.map((tag) => tag.name),
...episode.authors.map((author) => author.name), ...episode.authors.map((author) => author.name),
]} ]}
contentType={LPE.PostTypes.Podcast} contentType={LPE.PostTypes.Podcast}
@ -40,7 +40,7 @@ const EpisodePage = ({ episode, relatedEpisodes, errors }: EpisodeProps) => {
} }
export async function getStaticPaths() { export async function getStaticPaths() {
const { data } = await unbodyApi.getPodcastShows({ populateEpisodes: true }) const { data } = await strapiApi.getPodcastShows({ populateEpisodes: true })
const paths = data.flatMap((show) => { const paths = data.flatMap((show) => {
return ( return (
@ -81,25 +81,36 @@ export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
} }
// TODO : error handling // TODO : error handling
const { data: episode, errors: episodeErros } = const { data: episode, errors: episodeErros } = await strapiApi.getEpisode({
await unbodyApi.getPodcastEpisode({ showSlug: showSlug as string,
showSlug: showSlug as string, slug: epSlug as string,
slug: epSlug as string, published: true,
textBlocks: true, ...(id
...(id ? {
? { id: id as string,
id: id as string, published: false,
includeDraft: true, }
} : {}),
: {}), })
})
const { data: shows } = await strapiApi.getPodcastShows({})
// TODO : error handlings // TODO : error handlings
const { data: relatedEpisodes, errors: relatedEpisodesErros } = const { data: relatedEpisodes, errors: relatedEpisodesErros } =
await unbodyApi.getRelatedEpisodes({ await strapiApi
showSlug: showSlug as string, .getRelatedPosts({
id: episode?.id as string, id: episode?.id as string,
}) type: LPE.PostTypes.Podcast,
})
.then((data) => ({
...data,
data: data.data.map((post) => ({
...post,
show: shows.find(
(show) => show.id === (post as LPE.Podcast.Document).showId,
),
})),
}))
if (!episode) { if (!episode) {
return { return {
@ -112,7 +123,7 @@ export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
return { return {
props: { props: {
episode, episode,
relatedEpisodes, relatedEpisodes: relatedEpisodes,
}, },
revalidate: 10, revalidate: 10,
} }

View File

@ -1,10 +1,10 @@
import { SEO } from '@/components/SEO' import { SEO } from '@/components/SEO'
import PodcastShowContainer from '@/containers/PodcastShowContainer' import PodcastShowContainer from '@/containers/PodcastShowContainer'
import unbodyApi from '@/services/unbody/unbody.service'
import { GetStaticPropsContext } from 'next' import { GetStaticPropsContext } from 'next'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { DefaultLayout } from '../../../layouts/DefaultLayout' import { DefaultLayout } from '../../../layouts/DefaultLayout'
import { strapiApi } from '../../../services/strapi'
import { ApiPaginatedPayload } from '../../../types/data.types' import { ApiPaginatedPayload } from '../../../types/data.types'
import { LPE } from '../../../types/lpe.types' import { LPE } from '../../../types/lpe.types'
import { getPostLink } from '../../../utils/route.utils' import { getPostLink } from '../../../utils/route.utils'
@ -48,7 +48,7 @@ const PodcastShowPage = ({
} }
export async function getStaticPaths() { export async function getStaticPaths() {
const { data } = await unbodyApi.getPodcastShows({ populateEpisodes: false }) const { data } = await strapiApi.getPodcastShows({ populateEpisodes: false })
const paths = data.map((show) => { const paths = data.map((show) => {
return { return {
@ -76,30 +76,31 @@ export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
} }
// TODO : error handling // TODO : error handling
const { data: show, errors: podcastShowDataErrors } = const { data: shows, errors: podcastShowDataErrors } =
await unbodyApi.getPodcastShow({ await strapiApi.getPodcastShows({
showSlug: showSlug as string, slug: showSlug as string,
}) })
// TODO : error handling // TODO : error handling
const { data: latestEpisodes, errors: latestEpisodesErros } = const { data: latestEpisodes, errors: latestEpisodesErros } =
await unbodyApi.getLatestEpisodes({ await strapiApi.getLatestEpisodes({
showSlug: showSlug as string, showSlug: showSlug as string,
limit: 8, limit: 8,
}) })
// TODO : error handling // TODO : error handling
const { data: highlightedEpisodes, errors: highlightedEpisodesErrors } = const { data: highlightedEpisodes, errors: highlightedEpisodesErrors } =
await unbodyApi.getHighlightedEpisodes({ await strapiApi.getLatestEpisodes({
showSlug: showSlug as string, highlighted: 'only',
limit: 2, limit: 2,
showSlug: showSlug as string,
}) })
return { return {
props: { props: {
show, show: shows[0],
latestEpisodes, latestEpisodes,
highlightedEpisodes, highlightedEpisodes: highlightedEpisodes.data,
}, },
revalidate: 10, revalidate: 10,
} }

View File

@ -1,9 +1,9 @@
import { SEO } from '@/components/SEO' import { SEO } from '@/components/SEO'
import PodcastsContainer from '@/containers/PodcastsContainer' import PodcastsContainer from '@/containers/PodcastsContainer'
import unbodyApi from '@/services/unbody/unbody.service'
import { GetStaticPropsContext } from 'next' import { GetStaticPropsContext } from 'next'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { DefaultLayout } from '../../layouts/DefaultLayout' import { DefaultLayout } from '../../layouts/DefaultLayout'
import { strapiApi } from '../../services/strapi'
import { LPE } from '../../types/lpe.types' import { LPE } from '../../types/lpe.types'
import { getPostLink } from '../../utils/route.utils' import { getPostLink } from '../../utils/route.utils'
@ -43,18 +43,17 @@ const PodcastShowPage = ({
export const getStaticProps = async ({ params }: GetStaticPropsContext) => { export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
// TODO : error handling // TODO : error handling
const { data: podcastShows, errors: podcastShowsErrors } = const { data: podcastShows, errors: podcastShowsErrors } =
await unbodyApi.getPodcastShows({ await strapiApi.getPodcastShows({
populateEpisodes: true, populateEpisodes: true,
episodesLimit: 6, episodesLimit: 6,
}) })
// TODO : error handling // TODO : error handling
const { data: highlightedEpisodes, errors: highlightedEpisodesErrors } = const { data: highlightedEpisodes, errors: highlightedEpisodesErrors } =
await unbodyApi.getHighlightedEpisodes({}) await strapiApi.getLatestEpisodes({ highlighted: 'only' })
const { data: latestEpisodes } = await unbodyApi.getPodcastEpisodes({ const { data: latestEpisodes } = await strapiApi.getLatestEpisodes({
limit: 10, limit: 10,
populateShow: true,
highlighted: 'exclude', highlighted: 'exclude',
}) })
@ -65,20 +64,11 @@ export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
} }
} }
const latestEps = podcastShows
.flatMap((show) => show.episodes)
.sort((a, b) => {
const aDate = new Date(a!.publishedAt)
const bDate = new Date(b!.publishedAt)
return aDate > bDate ? -1 : 1
})
.filter((p) => p?.highlighted !== true)
return { return {
props: { props: {
shows: podcastShows, shows: podcastShows,
highlightedEpisodes, latestEpisodes: latestEpisodes.data,
latestEpisodes: latestEps, highlightedEpisodes: highlightedEpisodes.data,
// errors, // errors,
}, },
revalidate: 10, revalidate: 10,

View File

@ -1,6 +1,5 @@
import { CustomNextPage, GetServerSideProps } from 'next' import { CustomNextPage, GetServerSideProps } from 'next'
import SEO from '../components/SEO/SEO' import SEO from '../components/SEO/SEO'
import unbodyApi from '../services/unbody/unbody.service'
import { getPostLink } from '../utils/route.utils' import { getPostLink } from '../utils/route.utils'
type PageProps = {} type PageProps = {}
@ -20,10 +19,13 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
notFound: true, notFound: true,
} }
const { data, errors } = await unbodyApi.getDocById({ // const { data, errors } = await unbodyApi.getDocById({
id, // id,
includeDrafts: true, // includeDrafts: true,
}) // })
const data = undefined as any
const errors = '' as string
if (!data || errors) { if (!data || errors) {
return { return {

View File

@ -12,7 +12,6 @@ import { copyConfigs } from '../configs/copy.configs'
import { GlobalSearchBox } from '../containers/GlobalSearchBox/GlobalSearchBox' import { GlobalSearchBox } from '../containers/GlobalSearchBox/GlobalSearchBox'
import { DefaultLayout } from '../layouts/DefaultLayout' import { DefaultLayout } from '../layouts/DefaultLayout'
import { api } from '../services/api.service' import { api } from '../services/api.service'
import unbodyApi from '../services/unbody/unbody.service'
interface SearchPageProps { interface SearchPageProps {
topics: string[] topics: string[]
@ -118,11 +117,14 @@ SearchPage.getLayout = (page: ReactNode) => (
) )
export async function getStaticProps() { export async function getStaticProps() {
const { data: topics, errors: topicErrors } = await unbodyApi.getTopics() // const { data: topics, errors: topicErrors } = await unbodyApi.getTopics()
const { data: shows = [] } = await unbodyApi.getPodcastShows({ // const { data: shows = [] } = await unbodyApi.getPodcastShows({
populateEpisodes: true, // populateEpisodes: true,
episodesLimit: 10, // episodesLimit: 10,
}) // })
const topics = [] as any
const shows = [] as any
return { return {
props: { props: {

View File

@ -44,7 +44,8 @@ export const useRecentPosts = ({
) )
const posts = useMemo( const posts = useMemo(
() => (query.data?.pages || []).flatMap((page) => page.posts), () =>
(query.data?.pages || []).flatMap((page) => page.posts).filter(Boolean),
[query.data], [query.data],
) )

View File

@ -0,0 +1 @@
export * from './strapi.service'

View File

@ -0,0 +1,182 @@
import { gql } from '@apollo/client'
const POST_COMMON_ATTRIBUTES = gql`
fragment PostCommonAttributes on Post {
type
title
subtitle
summary
slug
featured
episode_number
podcast_show {
data {
id
}
}
cover_image {
data {
attributes {
url
width
height
caption
}
}
}
authors {
data {
id
attributes {
name
email_address
}
}
}
publish_date
publishedAt
tags {
data {
id
attributes {
name
}
}
}
}
`
export const GET_PODCAST_SHOWS = gql`
query GetPodcastShows(
$filters: PodcastShowFiltersInput
$pagination: PaginationArg
$sort: [String]
$publicationState: PublicationState
) {
podcastShows(
filters: $filters
pagination: $pagination
sort: $sort
publicationState: $publicationState
) {
data {
id
attributes {
name
slug
description
hosts {
data {
attributes {
name
email_address
}
}
}
logo {
data {
attributes {
url
width
height
}
}
}
}
}
}
}
`
export const GET_POSTS_QUERY = gql`
${POST_COMMON_ATTRIBUTES}
query GetPosts(
$filters: PostFiltersInput
$pagination: PaginationArg
$sort: [String]
$publicationState: PublicationState
$withContent: Boolean = false
) {
posts(
filters: $filters
pagination: $pagination
sort: $sort
publicationState: $publicationState
) {
meta {
pagination {
total
page
pageSize
pageCount
}
}
data {
id
attributes {
...PostCommonAttributes
body @include(if: $withContent)
credits @include(if: $withContent)
channel @include(if: $withContent) {
channel
link
}
}
}
}
}
`
export const GET_RELATED_POSTS_QUERY = gql`
${POST_COMMON_ATTRIBUTES}
query GetRelatedPosts($id: ID!, $type: String!) {
post(id: $id) {
data {
attributes {
related_posts(
publicationState: LIVE
filters: { type: { eq: $type } }
) {
data {
id
attributes {
...PostCommonAttributes
}
}
}
}
}
}
}
`
export const GET_STATIC_PAGES = gql`
query GetStaticPages(
$filters: PageFiltersInput
$pagination: PaginationArg
$sort: [String]
$publicationState: PublicationState
$withContent: Boolean = false
) {
pages(
sort: $sort
filters: $filters
pagination: $pagination
publicationState: $publicationState
) {
data {
id
attributes {
slug
title
subtitle
description
publishedAt
body @include(if: $withContent)
}
}
}
}
`

View File

@ -0,0 +1,502 @@
import { ApolloClient, InMemoryCache } from '@apollo/client'
import axios, { Axios } from 'axios'
import {
Enum_Post_Type,
GetPodcastShowsDocument,
GetPostsDocument,
GetRelatedPostsDocument,
GetStaticPagesDocument,
PodcastShowFiltersInput,
PostFiltersInput,
} from '../../lib/strapi/strapi.generated'
import { ApiResponse } from '../../types/data.types'
import { LPE } from '../../types/lpe.types'
import { settle } from '../../utils/promise.utils'
import { strapiTransformers } from './transformers/strapi.transformers'
export class StrapiService {
client: ApolloClient<any> = null as any
axios: Axios = null as any
constructor(apiUrl: string, graphqlUrl: string, apiKey: string) {
this.axios = axios.create({
baseURL: apiUrl,
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
podcast_shows: {
merge(existing = {}, incoming) {
return {
...existing,
...incoming,
}
},
},
pages: {
merge(existing = {}, incoming) {
return {
...existing,
...incoming,
}
},
},
posts: {
merge(existing = {}, incoming) {
return {
...existing,
...incoming,
}
},
},
},
},
},
})
this.client = new ApolloClient({
cache,
uri: graphqlUrl,
ssrMode: true,
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
}
handleResponse = <T>(
data: T | null = null,
errors: any = null,
): ApiResponse<T> => {
if (errors) console.error(errors)
if (errors || !data) {
return {
data: data as any,
errors: JSON.stringify(errors),
}
}
return {
data,
errors,
}
}
handleRequest = async <T = any>(
handler: () => T | Promise<T>,
defaultValue?: T,
): Promise<ApiResponse<T>> => {
const [res, err] = await settle<T>(handler)
if (err) return this.handleResponse(defaultValue || null, err)
return this.handleResponse(res)
}
getStaticPages = async ({
skip = 0,
limit = 10,
sort = ['publishedAt:desc'],
filters = {},
id,
slug,
published = true,
parseContent = false,
}: {
skip?: number
limit?: number
sort?: string[]
id?: string
slug?: string
filters?: PostFiltersInput
published?: boolean
parseContent?: boolean
}) =>
this.handleRequest<LPE.StaticPage.Document[]>(async () => {
const result: LPE.StaticPage.Document[] = []
const {
data: {
pages: { data },
},
} = await this.client.query({
query: GetStaticPagesDocument,
variables: {
pagination: {
start: skip,
limit: limit,
},
filters: {
and: [
...(filters ? [filters] : []),
...(slug ? [{ slug: { eq: slug } }] : []),
...(id ? [{ id: { eq: id } }] : []),
],
},
sort,
withContent: parseContent,
publicationState: published ? 'LIVE' : 'PREVIEW',
},
})
result.push(
...(await strapiTransformers.transformMany(
strapiTransformers.get({}),
data,
undefined,
undefined,
)),
)
return result
})
getPosts = async ({
skip = 0,
limit = 10,
sort = ['publish_date:desc'],
filters = {},
id,
slug,
published = true,
highlighted = 'include',
parseContent = false,
}: {
skip?: number
limit?: number
sort?: string[]
id?: string
slug?: string
filters?: PostFiltersInput
published?: boolean
highlighted?: 'only' | 'include' | 'exclude'
parseContent?: boolean
}) =>
this.handleRequest(async () => {
const result: LPE.Post.Document[] = []
const {
data: {
posts: { data, meta },
},
} = await this.client.query({
query: GetPostsDocument,
variables: {
pagination: {
start: skip,
limit: limit,
},
filters: {
and: [
...(filters ? [filters] : []),
...(slug ? [{ slug: { eq: slug } }] : []),
...(id ? [{ id: { eq: id } }] : []),
...(highlighted === 'only'
? [
{
featured: {
eq: true,
},
} as PostFiltersInput,
]
: highlighted === 'exclude'
? [
{
or: [
{
featured: {
eq: false,
},
},
{
featured: {
null: true,
},
},
],
} as PostFiltersInput,
]
: []),
],
},
sort,
withContent: parseContent,
publicationState: published ? 'LIVE' : 'PREVIEW',
},
})
result.push(
...(await strapiTransformers.transformMany(
strapiTransformers.get({}),
data,
undefined,
undefined,
)),
)
return {
data: result,
total: meta.pagination.total,
hasMore: meta.pagination.page < meta.pagination.pageCount,
}
})
getPodcastShows = async ({
skip = 0,
limit = 10,
sort = ['createdAt:desc'],
filters = {},
slug,
published = true,
populateEpisodes = false,
episodesLimit = 10,
}: {
skip?: number
limit?: number
sort?: string[]
slug?: string
filters?: PodcastShowFiltersInput
published?: boolean
populateEpisodes?: boolean
episodesLimit?: number
}) =>
this.handleRequest<LPE.Podcast.Show[]>(async () => {
const result: LPE.Podcast.Show[] = []
const {
data: {
podcastShows: { data },
},
} = await this.client.query({
query: GetPodcastShowsDocument,
variables: {
pagination: {
start: skip,
limit: limit,
},
filters: {
and: [
...(filters ? [filters] : []),
...(slug ? [{ slug: { eq: slug } }] : []),
],
},
sort,
publicationState: published ? 'LIVE' : 'PREVIEW',
},
})
result.push(
...(await strapiTransformers.transformMany(
strapiTransformers.get({}),
data,
undefined,
undefined,
)),
)
for (const show of result) {
const { data } = await this.getPosts({
filters: {
podcast_show: {
id: {
eq: show.id,
},
},
},
limit: episodesLimit,
highlighted: 'include',
parseContent: false,
published: true,
})
if (populateEpisodes)
show.episodes = data.data as LPE.Podcast.Document[]
show.numberOfEpisodes = data.total
}
return result
})
getRecentPosts = async ({
skip = 0,
limit = 10,
highlighted = 'exclude',
}: {
limit?: number
skip?: number
highlighted?: 'only' | 'include' | 'exclude'
}) =>
this.handleRequest(async () => {
const posts = await this.getPosts({
limit,
skip,
highlighted,
parseContent: false,
published: true,
})
return posts.data
})
getHighlightedPosts = async () =>
this.handleRequest(async () => {
const posts = await this.getPosts({
limit: 10,
highlighted: 'only',
parseContent: false,
published: true,
})
return posts.data.data
})
getLatestEpisodes = async ({
showSlug,
limit = 10,
skip = 0,
highlighted = 'include',
}: {
showSlug?: string
limit?: number
skip?: number
highlighted?: 'only' | 'include' | 'exclude'
}) =>
this.getPosts({
limit,
skip,
highlighted,
parseContent: false,
published: true,
filters: {
...(showSlug
? {
podcast_show: {
slug: {
eq: showSlug,
},
},
}
: {}),
type: {
eq: 'Episode' as Enum_Post_Type,
},
},
})
getEpisode = async ({
id,
slug,
showSlug,
published = true,
}: {
showSlug: string
slug?: string
id?: string
published?: boolean
}) =>
this.getPosts({
limit: 1,
highlighted: 'include',
parseContent: true,
published,
id,
slug,
filters: {
...(showSlug
? {
podcast_show: {
slug: {
eq: showSlug,
},
},
}
: {}),
type: {
eq: 'Episode' as Enum_Post_Type,
},
},
}).then((res) => ({
...res,
data: res.data.data[0] as LPE.Podcast.Document,
}))
getPostsFromSameAuthors = async ({
type,
authors,
excludeId,
}: {
authors: string[]
excludeId?: string
type: LPE.PostType
}) =>
this.getPosts({
limit: 10,
highlighted: 'include',
parseContent: false,
filters: {
and: [
{
authors: {
id: {
in: authors,
},
},
},
{
type: {
eq: type === 'article' ? 'Article' : 'Podcast',
},
},
...(excludeId
? [
{
id: {
ne: excludeId,
},
},
]
: []),
],
},
})
getRelatedPosts = async ({ id, type }: { id: string; type: LPE.PostType }) =>
this.handleRequest<LPE.Post.Document[]>(async () => {
const { data } = await this.client.query({
query: GetRelatedPostsDocument,
variables: {
id,
type:
type === 'article'
? ('Article' as Enum_Post_Type)
: ('Episode' as Enum_Post_Type),
},
})
return strapiTransformers.transformMany(
strapiTransformers.get({}),
data.post.data.attributes.related_posts.data,
undefined,
undefined,
)
})
getTopics = async () =>
this.handleRequest<LPE.Tag.Document[]>(async () => {
const { data } = await this.axios.get('/tags/getAll')
return data.map((tag: any) => ({
id: tag.id,
name: tag.name,
postsCount: tag.posts?.count ?? 0,
}))
})
}
export const strapiApi = new StrapiService(
process.env.STRAPI_API_URL || '',
process.env.STRAPI_GRAPHQL_URL || '',
process.env.STRAPI_API_KEY || '',
)

View File

@ -0,0 +1,10 @@
import {
GetPodcastShowsQuery,
GetPostsQuery,
GetStaticPagesQuery,
} from '../../lib/strapi/strapi.generated'
export type StrapiPostData = GetPostsQuery['posts']['data'][number]
export type StrapiPodcastShowData =
GetPodcastShowsQuery['podcastShows']['data'][number]
export type StrapiStaticPageData = GetStaticPagesQuery['pages']['data'][number]

View File

@ -0,0 +1,104 @@
import { Transformer } from '../../../lib/TransformPipeline/types'
import { LPE } from '../../../types/lpe.types'
import { settle } from '../../../utils/promise.utils'
import { simplecastApi } from '../../simplecast.service'
import { StrapiPostData } from '../strapi.types'
import { transformStrapiHtmlContent } from './utils'
export const episodeTransformer: Transformer<
LPE.Podcast.Document,
LPE.Podcast.Document,
StrapiPostData,
undefined,
undefined
> = {
key: 'EpisodeTransformer',
classes: ['episode'],
objectType: 'Post',
isMatch: (helpers, object) => object.type === LPE.PostTypes.Podcast,
transform: async (helpers, data, original, root, ctx) => {
return {
...data,
credits: transformStrapiHtmlContent({
html: original.attributes.credits || '',
}).blocks as LPE.Post.TextBlock[],
channels: original.attributes.channel
? await transformChannels(original.attributes.channel)
: [],
}
},
}
const transformChannels = async (
channels: StrapiPostData['attributes']['channel'] = [],
) => {
const transformed: LPE.Podcast.Content['channels'] = []
for (const channel of channels) {
const { channel: name, link } = channel
switch (name) {
case 'Apple_Podcasts':
transformed.push({
name: LPE.Podcast.ChannelNames.ApplePodcasts,
url: link,
})
break
case 'Google_Podcasts': {
transformed.push({
name: LPE.Podcast.ChannelNames.GooglePodcasts,
url: link,
})
break
}
case 'Spotify': {
transformed.push({
name: LPE.Podcast.ChannelNames.Spotify,
url: link,
})
break
}
case 'Youtube': {
transformed.push({
name: LPE.Podcast.ChannelNames.Youtube,
url: link,
})
break
}
case 'Simplecast': {
const episodeId = simplecastApi.extractEpisodeIdFromUrl(link)
if (!episodeId) {
console.error('invalid Simplecast player url!')
continue
}
const [res, err] = await settle(() =>
simplecastApi.getEpisode({ id: episodeId }),
)
if (err) {
console.error('failed to fetch Simplecast episode ', link)
console.error(err)
continue
}
transformed.push({
name: LPE.Podcast.ChannelNames.Simplecast,
url: link,
data: {
duration: res.duration,
audioFileUrl: res.ad_free_audio_file_url ?? res.audio_file?.url,
},
})
break
}
}
}
return transformed
}

View File

@ -0,0 +1,39 @@
import { Transformer } from '../../../lib/TransformPipeline/types'
import { LPE } from '../../../types/lpe.types'
import { getPostLink } from '../../../utils/route.utils'
import { StrapiPodcastShowData } from '../strapi.types'
import { transformStrapiHtmlContent, transformStrapiImageData } from './utils'
export const podcastShowTransformer: Transformer<
StrapiPodcastShowData,
LPE.Podcast.Show,
StrapiPodcastShowData,
undefined,
undefined
> = {
key: 'PodcastShowTransformer',
classes: ['podcast'],
objectType: 'PodcastShow',
isMatch: (helpers, object) => object.__typename === 'PodcastShowEntity',
transform: (helpers, data, original, root, ctx) => {
const { id, attributes } = data
return {
id,
slug: attributes.slug,
title: attributes.name,
numberOfEpisodes: 0,
url: getPostLink('podcast', { showSlug: attributes.slug }),
description: attributes.description,
descriptionText: transformStrapiHtmlContent({
html: attributes.description || '',
}).text,
hosts: attributes.hosts.data.map((host) => ({
id: '',
name: host.attributes.name,
emailAddress: host.attributes.email_address,
})),
logo: transformStrapiImageData(attributes.logo),
}
},
}

View File

@ -0,0 +1,109 @@
import { Transformer } from '../../../lib/TransformPipeline/types'
import { LPE } from '../../../types/lpe.types'
import { calcReadingTime } from '../../../utils/string.utils'
import { StrapiPostData } from '../strapi.types'
import { transformStrapiHtmlContent, transformStrapiImageData } from './utils'
export const postTransformer: Transformer<
StrapiPostData,
LPE.Post.Document,
StrapiPostData,
undefined,
undefined
> = {
key: 'PostTransformer',
classes: ['post'],
objectType: 'Post',
isMatch: (helpers, object) => object.__typename === 'PostEntity',
transform: (helpers, data, original, root, ctx) => {
const { id, attributes } = data
const type = attributes.type
const title = attributes.title
const subtitle = attributes.subtitle
const slug = attributes.slug
const publishedAt = attributes.publish_date
const isHighlighted = attributes.featured
const isDraft = !attributes.publishedAt
const coverImage: LPE.Post.Document['coverImage'] =
transformStrapiImageData(attributes.cover_image)
const tags: LPE.Tag.Document[] = attributes.tags.data.map((tag) => ({
id: tag.id,
name: tag.attributes.name,
}))
const authors = attributes.authors.data.map((author) => ({
id: author.id,
name: author.attributes.name,
emailAddress: author.attributes.email_address,
}))
const summary = transformStrapiHtmlContent({
html: attributes.summary || '',
}).text
const {
blocks: content,
toc,
text,
} = transformStrapiHtmlContent({
html: attributes.body || '',
})
// add the title as the first toc item
{
toc.unshift({
href: '#title-anchor',
blockIndex: -1,
level: 0,
tag: 'h1',
title,
})
}
if (type === 'Article') {
return {
id,
title,
subtitle,
slug,
modifiedAt: publishedAt,
createdAt: publishedAt,
coverImage,
tags,
content,
summary,
readingTime: calcReadingTime(text),
toc,
type: 'article',
authors,
highlighted: isHighlighted,
isDraft,
} as LPE.Article.Data
} else {
return {
id,
title,
subtitle,
slug,
publishedAt: publishedAt,
createdAt: publishedAt,
coverImage,
tags,
channels: [],
description: summary,
type: 'podcast',
isDraft,
highlighted: isHighlighted,
authors,
content,
episodeNumber: attributes.episode_number,
showId: attributes.podcast_show.data?.id || null,
modifiedAt: publishedAt,
// will be filled in later
credits: [],
transcription: [],
} as LPE.Podcast.Document
}
},
}

View File

@ -0,0 +1,42 @@
import { Transformer } from '../../../lib/TransformPipeline/types'
import { LPE } from '../../../types/lpe.types'
import { StrapiStaticPageData } from '../strapi.types'
import { transformStrapiHtmlContent } from './utils'
export const staticPageTransformer: Transformer<
StrapiStaticPageData,
LPE.StaticPage.Document,
StrapiStaticPageData,
undefined,
undefined
> = {
key: 'StaticPageTransformer',
classes: ['static_page'],
objectType: 'StaticPage',
isMatch: (helpers, object) => object.__typename === 'PageEntity',
transform: (helpers, data, original, root, ctx) => {
const { id, attributes } = data
const title = attributes.title
const subtitle = attributes.subtitle
const slug = attributes.slug
const description = attributes.description
const { blocks: content } = transformStrapiHtmlContent({
html: attributes.body || '',
})
return {
id,
slug,
title,
subtitle,
content,
summary: description,
createdAt: attributes.publishedAt,
modifiedAt: attributes.publishedAt,
isDraft: !attributes.publishedAt,
type: 'static_page',
}
},
}

View File

@ -0,0 +1,12 @@
import { TransformPipeline } from '../../../lib/TransformPipeline/TransformPipeline'
import { episodeTransformer } from './Episode.transformer'
import { podcastShowTransformer } from './PodcastShow.transformer'
import { postTransformer } from './Post.transformer'
import { staticPageTransformer } from './StaticPage.transformer'
export const strapiTransformers = new TransformPipeline([
podcastShowTransformer,
staticPageTransformer,
postTransformer,
episodeTransformer,
])

View File

@ -0,0 +1,187 @@
import * as htmlParser from 'node-html-parser'
import slugify from 'slugify'
import { UploadFileEntity } from '../../../lib/strapi/strapi.generated'
import { LPE } from '../../../types/lpe.types'
import { convertToIframe } from '../../../utils/string.utils'
let assetsBaseUrl = process.env.NEXT_PUBLIC_ASSETS_BASE_URL ?? ''
if (assetsBaseUrl.endsWith('/')) assetsBaseUrl = assetsBaseUrl.slice(1)
export const transformStrapiImageUrl = (url: string): string =>
assetsBaseUrl + url
export const transformStrapiImageData = (
image:
| Pick<UploadFileEntity, 'attributes'>
| {
data: {
attributes: Partial<UploadFileEntity['attributes']>
}
},
): LPE.Image.Document => {
const attributes = 'data' in image ? image.data.attributes : image.attributes
return {
height: attributes.height || 0,
width: attributes.width || 0,
caption: attributes.caption || '',
alt: attributes.caption || attributes.alternativeText || '',
url: attributes.url ? transformStrapiImageUrl(attributes.url) : '',
}
}
export const transformStrapiHtmlContent = ({
html,
}: {
html: string
}): {
toc: LPE.Post.TocItem[]
blocks: LPE.Post.ContentBlock[]
html: string
text: string
} => {
const toc: LPE.Post.TocItem[] = []
const blocks: LPE.Post.ContentBlock[] = []
// split paragraphs with <br> into multiple paragraphs
html = html.replaceAll(
/<p(\s+[^>]*)?>(.*?)<br>(.*?)<\/p>/g,
(match, p1, p2, p3) =>
[p2, p3]
.join('<br>')
.split('<br>')
.map((p) => `<p${p1 || ''}>${p}</p>`)
.join(''),
)
let root = htmlParser.parse(html, { parseNoneClosedTags: true })
let blockIndex = -1
for (const child of root.childNodes) {
if (!(child instanceof htmlParser.HTMLElement)) {
continue
}
const tagName = child.tagName.toLowerCase()
const isFigure = tagName === 'figure'
const isMedia = isFigure && !!child.querySelector('oembed')
const isImage = isFigure && !isMedia && !!child.querySelector('img')
const empty = child.text.length === 0
if (!isFigure && empty) continue
blockIndex++
if (isImage) {
const image = child.querySelector('img')
if (!image) {
blockIndex--
continue
}
const caption = child.textContent || ''
const alt = image.getAttribute('alt') || ''
const url = image.getAttribute('src') || ''
const width = parseInt(image.getAttribute('width') || '0', 10)
const height = parseInt(image.getAttribute('height') || '0', 10)
blocks.push({
id: '',
caption,
type: 'image',
width,
height,
alt: alt || caption,
labels: [],
order: blockIndex,
url: url.startsWith('/') ? transformStrapiImageUrl(url) : url,
})
continue
}
if (isMedia) {
const labels: LPE.Post.ContentBlockLabel[] = ['embed']
const oembed = child.querySelector('oembed')
const url = oembed?.getAttribute('url') || ''
if (!url) {
blockIndex--
continue
}
const youtube = html.match(
/(https?\:\/\/)?((www\.)?youtube\.com|youtu\.?be)\/[^ "]+/gi,
)
const simplecast = html.match(
/(https?\:\/\/)?((player\.)?simplecast\.com)\/[^ "]+/gi,
)
const label = youtube?.[0]
? LPE.Post.ContentBlockLabels.YoutubeEmbed
: LPE.Post.ContentBlockLabels.SimplecastEmbed
const src = youtube?.[0] || simplecast?.[0]
if (src && src.length > 0) {
labels.push(label)
labels.push(LPE.Post.ContentBlockLabels.Embed)
}
blocks.push({
id: `p-${blockIndex}`,
order: 0,
type: 'text',
classNames: [],
footnotes: [],
html: child.outerHTML,
labels,
tagName: 'p',
text: url,
embed: {
src: url,
html: convertToIframe(url),
},
})
}
const text = child.text || ''
const isHeading = tagName.startsWith('h')
const id =
child.id ||
(isHeading && slugify(text, { lower: true, trim: true })) ||
`p-${blockIndex}`
child.setAttribute('id', id)
if (isHeading) {
toc.push({
blockIndex,
href: `#${id}`,
level: parseInt(tagName[1], 10),
tag: tagName,
title: text.trim(),
})
}
blocks.push({
id: '',
footnotes: [],
html: child.outerHTML,
labels: [],
tagName,
text,
order: blockIndex,
classNames: Array.from(child.classList.values()),
type: 'text',
} as LPE.Post.TextBlock)
}
return {
toc,
blocks,
text: root.text,
html: root.innerHTML,
}
}

View File

@ -65,6 +65,6 @@ export const ArticleDataType: UnbodyDataTypeConfig<
highlighted: data.path.includes('highlighted'), highlighted: data.path.includes('highlighted'),
isDraft: data.path.includes('draft'), isDraft: data.path.includes('draft'),
type: LPE.PostTypes.Article, type: LPE.PostTypes.Article,
} } as any
}, },
} }

View File

@ -1798,8 +1798,8 @@ unbodyApi.onChange(async (oldData, data, changes, firstLoad) => {
...post.tags.map( ...post.tags.map(
(tag) => (tag) =>
({ ({
name: formatTagText(tag), name: formatTagText(tag.name),
domain: formatTagText(tag), domain: formatTagText(tag.name),
} as Category), } as Category),
), ),
], ],

View File

@ -8,17 +8,27 @@ export namespace LPE {
export type PostType = DictValues<typeof PostTypes> export type PostType = DictValues<typeof PostTypes>
export namespace Tag {
export type Document = {
id: string
name: string
postsCount?: number
}
}
export namespace Image { export namespace Image {
export type Document = { export type Document = {
url: string url: string
alt: string alt: string
width: number width: number
height: number height: number
caption?: string
} }
} }
export namespace Author { export namespace Author {
export type Document = { export type Document = {
id: string
name: string name: string
emailAddress?: string emailAddress?: string
} }
@ -153,7 +163,7 @@ export namespace LPE {
summary: string summary: string
subtitle: string subtitle: string
authors: Author.Document[] authors: Author.Document[]
tags: string[] tags: Tag.Document[]
highlighted?: boolean highlighted?: boolean
isDraft?: boolean isDraft?: boolean
@ -220,7 +230,7 @@ export namespace LPE {
id: string id: string
slug: string slug: string
title: string title: string
tags: string[] tags: Tag.Document[]
description: string description: string
authors: Author.Document[] authors: Author.Document[]
publishedAt: string publishedAt: string
@ -229,7 +239,7 @@ export namespace LPE {
showId?: string showId?: string
highlighted?: boolean highlighted?: boolean
isDraft?: boolean isDraft?: boolean
coverImage?: Post.ImageBlock coverImage?: Image.Document
show?: Show show?: Show
type: typeof LPE.PostTypes.Podcast type: typeof LPE.PostTypes.Podcast
} }

View File

@ -2070,6 +2070,11 @@ bl@^4.1.0:
inherits "^2.0.4" inherits "^2.0.4"
readable-stream "^3.4.0" readable-stream "^3.4.0"
boolbase@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
brace-expansion@^1.1.7: brace-expansion@^1.1.7:
version "1.1.11" version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@ -2447,6 +2452,17 @@ css-in-js-utils@^3.1.0:
dependencies: dependencies:
hyphenate-style-name "^1.0.3" hyphenate-style-name "^1.0.3"
css-select@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6"
integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==
dependencies:
boolbase "^1.0.0"
css-what "^6.1.0"
domhandler "^5.0.2"
domutils "^3.0.1"
nth-check "^2.0.1"
css-to-react-native@^3.0.0: css-to-react-native@^3.0.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-3.2.0.tgz#cdd8099f71024e149e4f6fe17a7d46ecd55f1e32" resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-3.2.0.tgz#cdd8099f71024e149e4f6fe17a7d46ecd55f1e32"
@ -2464,6 +2480,11 @@ css-tree@^1.1.2:
mdn-data "2.0.14" mdn-data "2.0.14"
source-map "^0.6.1" source-map "^0.6.1"
css-what@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
csstype@^3.0.2, csstype@^3.0.6: csstype@^3.0.2, csstype@^3.0.6:
version "3.1.2" version "3.1.2"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
@ -2629,6 +2650,36 @@ doctrine@^3.0.0:
dependencies: dependencies:
esutils "^2.0.2" esutils "^2.0.2"
dom-serializer@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.2"
entities "^4.2.0"
domelementtype@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
dependencies:
domelementtype "^2.3.0"
domutils@^3.0.1:
version "3.1.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e"
integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==
dependencies:
dom-serializer "^2.0.0"
domelementtype "^2.3.0"
domhandler "^5.0.3"
dot-case@^3.0.4: dot-case@^3.0.4:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751"
@ -2695,6 +2746,11 @@ enhanced-resolve@^5.12.0:
graceful-fs "^4.2.4" graceful-fs "^4.2.4"
tapable "^2.2.0" tapable "^2.2.0"
entities@^4.2.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
error-ex@^1.3.1: error-ex@^1.3.1:
version "1.3.2" version "1.3.2"
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
@ -3493,6 +3549,11 @@ has@^1.0.3:
dependencies: dependencies:
function-bind "^1.1.1" function-bind "^1.1.1"
he@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
header-case@^2.0.4: header-case@^2.0.4:
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/header-case/-/header-case-2.0.4.tgz#5a42e63b55177349cf405beb8d775acabb92c063" resolved "https://registry.yarnpkg.com/header-case/-/header-case-2.0.4.tgz#5a42e63b55177349cf405beb8d775acabb92c063"
@ -4376,6 +4437,14 @@ node-fetch@^2.6.1, node-fetch@^2.6.12:
dependencies: dependencies:
whatwg-url "^5.0.0" whatwg-url "^5.0.0"
node-html-parser@^6.1.12:
version "6.1.12"
resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-6.1.12.tgz#6138f805d0ad7a6b5ef415bcd91bca07374bf575"
integrity sha512-/bT/Ncmv+fbMGX96XG9g05vFt43m/+SYKIs9oAemQVYyVcZmDAI2Xq/SbNcpOA35eF0Zk2av3Ksf+Xk8Vt8abA==
dependencies:
css-select "^5.1.0"
he "1.2.0"
node-int64@^0.4.0: node-int64@^0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
@ -4410,6 +4479,13 @@ nprogress@^0.2.0:
resolved "https://registry.yarnpkg.com/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1" resolved "https://registry.yarnpkg.com/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1"
integrity sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA== integrity sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==
nth-check@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==
dependencies:
boolbase "^1.0.0"
nullthrows@^1.1.1: nullthrows@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1"
@ -5250,6 +5326,11 @@ slice-ansi@^5.0.0:
ansi-styles "^6.0.0" ansi-styles "^6.0.0"
is-fullwidth-code-point "^4.0.0" is-fullwidth-code-point "^4.0.0"
slugify@^1.6.6:
version "1.6.6"
resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b"
integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==
snake-case@^3.0.4: snake-case@^3.0.4:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c"