@graphcommerce/hygraph-ui 9.0.0-canary.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Change Log
2
+
3
+ ## 9.0.0-canary.103
4
+
5
+ ### Patch Changes
6
+
7
+ - [#2421](https://github.com/graphcommerce-org/graphcommerce/pull/2421) [`f71b4e2`](https://github.com/graphcommerce-org/graphcommerce/commit/f71b4e2d13e54dd311eb1465a49df41703b6fef5) - Renamed from `@graphcommerce/graphcms-ui` to `@graphcommerce/hygraph-ui`. See [CHANGELOG.md](../graphcms-ui/CHANGELOG.md) for details. ([@paales](https://github.com/paales))
@@ -0,0 +1,17 @@
1
+ extend input GraphCommerceConfig {
2
+ """
3
+ The HyGraph endpoint.
4
+
5
+ > Read-only endpoint that allows low latency and high read-throughput content delivery.
6
+
7
+ Project settings -> API Access -> High Performance Read-only Content API
8
+ """
9
+ hygraphEndpoint: String!
10
+ }
11
+
12
+ extend input GraphCommerceStorefrontConfig {
13
+ """
14
+ Add a gcms-locales header to make sure queries return in a certain language, can be an array to define fallbacks.
15
+ """
16
+ hygraphLocales: [String!]
17
+ }
package/README.md ADDED
@@ -0,0 +1,4 @@
1
+ ## Configuration
2
+
3
+ Configure the following ([configuration values](./Config.graphqls)) in your
4
+ graphcommerce.config.js
@@ -0,0 +1,7 @@
1
+ fragment Asset on Asset {
2
+ url
3
+ width
4
+ height
5
+ mimeType
6
+ size
7
+ }
@@ -0,0 +1,61 @@
1
+ import type { ImageProps } from '@graphcommerce/image'
2
+ import { Image } from '@graphcommerce/image'
3
+ import type { SxProps, Theme } from '@mui/material'
4
+ import { styled } from '@mui/material'
5
+ import { memo } from 'react'
6
+ import type { AssetFragment } from './Asset.gql'
7
+
8
+ export type { AssetFragment } from './Asset.gql'
9
+
10
+ type ImageAsset = Omit<AssetFragment, 'width' | 'height'> & {
11
+ width: number
12
+ height: number
13
+ }
14
+
15
+ function isImage(asset: AssetFragment): asset is ImageAsset {
16
+ return !!(asset.width && asset.height)
17
+ }
18
+
19
+ type AssetProps = {
20
+ asset: AssetFragment
21
+ sx?: SxProps<Theme>
22
+ } & Omit<ImageProps, 'src' | 'width' | 'height' | 'alt' | 'sx'>
23
+
24
+ export const Asset = memo<AssetProps>((props) => {
25
+ const { asset, sx = [], ...imgProps } = props
26
+
27
+ if (isImage(asset)) {
28
+ const { url, height, mimeType, size, width, alt, ...assetProps } = asset
29
+ return (
30
+ <Image
31
+ src={url}
32
+ height={height}
33
+ width={width}
34
+ alt={alt ?? undefined}
35
+ {...imgProps}
36
+ {...assetProps}
37
+ unoptimized={mimeType === 'image/svg+xml'}
38
+ sx={[...(Array.isArray(sx) ? sx : [sx])]}
39
+ />
40
+ )
41
+ }
42
+
43
+ if (asset.mimeType === 'video/mp4') {
44
+ const Video = styled('video')({})
45
+
46
+ return (
47
+ <Video
48
+ src={asset.url}
49
+ autoPlay
50
+ muted
51
+ loop
52
+ playsInline
53
+ disableRemotePlayback
54
+ sx={[...(Array.isArray(sx) ? sx : [sx])]}
55
+ />
56
+ )
57
+ }
58
+
59
+ if (process.env.NODE_ENV !== 'production') return <div>{asset.mimeType} not supported</div>
60
+ return null
61
+ })
@@ -0,0 +1,199 @@
1
+ /* eslint-disable @typescript-eslint/no-use-before-define */
2
+ import type { SxProps, Theme } from '@mui/material'
3
+ import React from 'react'
4
+ import { defaultRenderers } from './defaultRenderers'
5
+ import { defaultSxRenderer } from './defaultSxRenderer'
6
+ import type {
7
+ AdditionalProps,
8
+ Renderers,
9
+ Renderer,
10
+ SxRenderer,
11
+ TextNode,
12
+ ElementOrTextNode,
13
+ ElementNode,
14
+ SimpleElement,
15
+ } from './types'
16
+
17
+ const sxArr = (sxAny?: SxProps<Theme> | false) => {
18
+ if (!sxAny) return []
19
+ return Array.isArray(sxAny) ? sxAny : [sxAny]
20
+ }
21
+
22
+ function useRenderProps(
23
+ { first, last, sxRenderer }: Pick<AdditionalProps, 'first' | 'last' | 'sxRenderer'>,
24
+ type?: ElementNode['type'],
25
+ ) {
26
+ if (!type) return []
27
+ const sx: SxProps<Theme> = sxRenderer?.[type] ?? []
28
+
29
+ return [
30
+ ...sxArr(sxRenderer.all),
31
+ ...sxArr(sx),
32
+ ...sxArr(first && sxRenderer.first),
33
+ ...sxArr(last && sxRenderer.last),
34
+ ]
35
+ }
36
+
37
+ function LineBreakToBr(props: { text: string }) {
38
+ const { text } = props
39
+
40
+ const textA = text.split('\n')
41
+ const textArray: React.ReactNode[] = []
42
+ textA.forEach((val, index) => {
43
+ textArray.push(val)
44
+ if (index < textA.length - 1) textArray.push(<br />)
45
+ })
46
+
47
+ // eslint-disable-next-line react/no-array-index-key
48
+ return textArray.map((val, idx) => <React.Fragment key={idx}>{val}</React.Fragment>)
49
+ }
50
+
51
+ function RenderText({
52
+ text,
53
+ renderers,
54
+ sxRenderer,
55
+ first,
56
+ last,
57
+ ...textProps
58
+ }: TextNode & AdditionalProps) {
59
+ let type: 'bold' | 'italic' | 'underlined' | undefined
60
+
61
+ if (textProps.bold) type = 'bold'
62
+ if (textProps.italic) type = 'italic'
63
+ if (textProps.underlined) type = 'underlined'
64
+
65
+ const sx = useRenderProps({ first, last, sxRenderer }, type)
66
+
67
+ if (!type) return <LineBreakToBr text={text} />
68
+ const Component: Renderer<SimpleElement> = renderers[type]
69
+ return (
70
+ <Component sx={sx}>
71
+ <LineBreakToBr text={text} />
72
+ </Component>
73
+ )
74
+ }
75
+
76
+ export function isTextNode(node: ElementOrTextNode): node is TextNode {
77
+ return (node as TextNode).text !== undefined
78
+ }
79
+
80
+ export function isElementNode(node: ElementOrTextNode): node is ElementNode {
81
+ return (node as ElementNode).children !== undefined
82
+ }
83
+
84
+ function RenderNode(node: ElementOrTextNode & AdditionalProps) {
85
+ if (isTextNode(node)) {
86
+ return <RenderText {...node} />
87
+ }
88
+ if (isElementNode(node)) {
89
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
90
+ return <RenderElement {...node} />
91
+ }
92
+
93
+ if (process.env.NODE_ENV !== 'production') {
94
+ console.error(node)
95
+ throw Error('RichText: Node not recognized')
96
+ }
97
+
98
+ return null
99
+ }
100
+
101
+ function RenderChildren({
102
+ childNodes,
103
+ noMargin,
104
+ ...props
105
+ }: { childNodes: ElementNode['children']; noMargin?: boolean } & AdditionalProps) {
106
+ return (
107
+ <>
108
+ {childNodes.map((node, key) => (
109
+ <RenderNode
110
+ {...node}
111
+ {...props}
112
+ // Since we don't know any unique identifiers of the element and since this doesn't rerender often this is fine.
113
+ // eslint-disable-next-line react/no-array-index-key
114
+ key={key}
115
+ first={noMargin && key === 0}
116
+ last={noMargin && key === childNodes.length - 1}
117
+ />
118
+ ))}
119
+ </>
120
+ )
121
+ }
122
+
123
+ function RenderElement(element: ElementNode & AdditionalProps) {
124
+ const { type, children, sxRenderer, renderers, first, last, ...props } = element
125
+
126
+ // todo: this has the any type, could be improved
127
+ const Component: Renderer<SimpleElement> = renderers[type]
128
+ const sx = useRenderProps({ first, last, sxRenderer }, type)
129
+
130
+ if (Component) {
131
+ return (
132
+ <Component {...props} sx={sx}>
133
+ <RenderChildren childNodes={children} sxRenderer={sxRenderer} renderers={renderers} />
134
+ </Component>
135
+ )
136
+ }
137
+
138
+ if (process.env.NODE_ENV !== 'production') {
139
+ console.error(element)
140
+ throw Error(`RichText: Unknown Element: ${type}`)
141
+ }
142
+ return <RenderChildren childNodes={children} sxRenderer={sxRenderer} renderers={renderers} />
143
+ }
144
+
145
+ function mergeSxRenderer(base: SxRenderer, sxRenderer?: SxRenderer) {
146
+ if (!sxRenderer) return base
147
+
148
+ return Object.fromEntries(
149
+ Object.entries<SxProps<Theme>>(base).map(([key, sx]) => {
150
+ const sxOverride: SxProps<Theme> = sxRenderer?.[key]
151
+
152
+ return sxOverride
153
+ ? [
154
+ key,
155
+ [
156
+ ...(Array.isArray(sx) ? sx : [sx]),
157
+ ...(Array.isArray(sxOverride) ? sxOverride : [sxOverride]),
158
+ ],
159
+ ]
160
+ : [key, sx]
161
+ }),
162
+ ) as SxRenderer
163
+ }
164
+
165
+ export type RichTextProps = { raw: ElementNode } & {
166
+ renderers?: Partial<Renderers>
167
+ /**
168
+ * Allows you to theme all the types of components
169
+ *
170
+ * ```tsx
171
+ * function MyComponent()f {
172
+ * return <RichText
173
+ * sxRenderer={{
174
+ * paragraph: (theme) => ({
175
+ * columnCount: { xs: 1, md: getColumnCount(props, 2) },
176
+ * columnGap: theme.spacings.md,
177
+ * }),
178
+ * //other props here
179
+ * }}
180
+ * />
181
+ * }
182
+ * ```
183
+ */
184
+ sxRenderer?: SxRenderer
185
+
186
+ /** By default the component will render the first and last element without any margins */
187
+ withMargin?: boolean
188
+ }
189
+
190
+ export function RichText({ raw, sxRenderer, renderers, withMargin = false }: RichTextProps) {
191
+ return (
192
+ <RenderChildren
193
+ childNodes={raw.children}
194
+ sxRenderer={mergeSxRenderer(defaultSxRenderer, sxRenderer)}
195
+ renderers={{ ...defaultRenderers, ...renderers }}
196
+ noMargin={!withMargin}
197
+ />
198
+ )
199
+ }
@@ -0,0 +1,58 @@
1
+ import { Box, Typography, Link } from '@mui/material'
2
+ import { Asset } from '../Asset/Asset'
3
+ import type { Renderers } from './types'
4
+
5
+ export const defaultRenderers: Renderers = {
6
+ 'heading-one': (props) => <Typography variant='h1' {...props} />,
7
+ 'heading-two': (props) => <Typography variant='h2' {...props} />,
8
+ 'heading-three': (props) => <Typography variant='h3' {...props} />,
9
+ 'heading-four': (props) => <Typography variant='h4' {...props} />,
10
+ 'heading-five': (props) => <Typography variant='h5' {...props} />,
11
+ 'heading-six': (props) => <Typography variant='h6' {...props} />,
12
+ paragraph: (props) => <Typography variant='body1' gutterBottom {...props} />,
13
+ 'bulleted-list': (props) => <Box component='ul' {...props} />,
14
+ 'numbered-list': (props) => <Box component='ol' {...props} />,
15
+ 'list-item': (props) => <Box component='li' {...props} />,
16
+ 'list-item-child': (props) => <Box component='span' {...props} />,
17
+ 'block-quote': (props) => <Box component='blockquote' {...props} />,
18
+ iframe: (props) => {
19
+ const { url, width, height, sx = [] } = props
20
+ return (
21
+ // todo add security attributes to iframe
22
+ // todo make iframe responsive (generic IFrame component?)
23
+ <Box
24
+ component='iframe'
25
+ src={url}
26
+ loading='lazy'
27
+ sx={[
28
+ { aspectRatio: `${width} / ${height}`, width: '100%' },
29
+ ...(Array.isArray(sx) ? sx : [sx]),
30
+ ]}
31
+ />
32
+ )
33
+ },
34
+ image: ({ src, width, height, title, altText, mimeType, sx }) => (
35
+ <Box sx={sx}>
36
+ <Asset asset={{ url: src, alt: altText ?? title, width, height, mimeType }} />
37
+ </Box>
38
+ ),
39
+ video: ({ src, width, height, title, mimeType, sx }) => (
40
+ <Box sx={sx}>
41
+ <Asset asset={{ url: src, alt: title, width, height, mimeType }} />
42
+ </Box>
43
+ ),
44
+ link: ({ href, openInNewTab, ...props }) => (
45
+ <Link href={href} underline='hover' {...props} target={openInNewTab ? '_blank' : undefined} />
46
+ ),
47
+ table: (props) => <Box component='table' {...props} />,
48
+ table_head: (props) => <Box component='thead' {...props} />,
49
+ table_header_cell: (props) => <Box component='th' {...props} />,
50
+ table_body: (props) => <Box component='tbody' {...props} />,
51
+ table_row: (props) => <Box component='tr' {...props} />,
52
+ table_cell: (props) => <Box component='td' {...props} />,
53
+ code: (props) => <Box component='code' {...props} />,
54
+ bold: (props) => <Box component='strong' fontWeight='bold' {...props} />,
55
+ italic: (props) => <Box component='em' fontStyle='italic' {...props} />,
56
+ underlined: (props) => <Box component='span' {...props} />,
57
+ class: (props) => <Box component='div' {...props} />,
58
+ }
@@ -0,0 +1,108 @@
1
+ import type { SxRenderer } from './types'
2
+
3
+ export const defaultSxRenderer: SxRenderer = {
4
+ all: {
5
+ '&:empty:not(iframe)': {
6
+ display: 'none',
7
+ },
8
+ },
9
+ first: {
10
+ marginTop: 0,
11
+ },
12
+ last: {
13
+ marginBottom: 0,
14
+ },
15
+ paragraph: {
16
+ marginBottom: '1em',
17
+ wordBreak: 'break-word',
18
+ },
19
+ 'heading-one': {
20
+ marginTop: '0.5em',
21
+ marginBottom: '0.5em',
22
+ },
23
+ 'heading-two': {
24
+ marginTop: '0.5em',
25
+ marginBottom: '0.5em',
26
+ },
27
+ 'heading-three': {
28
+ marginTop: '0.5em',
29
+ marginBottom: '0.5em',
30
+ },
31
+ 'heading-four': {
32
+ marginTop: '0.5em',
33
+ marginBottom: '0.5em',
34
+ },
35
+ 'heading-five': {
36
+ marginTop: '0.5em',
37
+ marginBottom: '0.5em',
38
+ },
39
+ image: {
40
+ width: '100%',
41
+ height: 'auto',
42
+ },
43
+ video: {
44
+ width: '100%',
45
+ height: 'auto',
46
+ },
47
+ 'block-quote': (theme) => ({
48
+ paddingLeft: theme.spacings.sm,
49
+ margin: `${theme.spacings.md} 0`,
50
+ }),
51
+ 'bulleted-list': {
52
+ marginBottom: '1em',
53
+ },
54
+ 'numbered-list': {
55
+ marginBottom: '1em',
56
+ },
57
+ code: {
58
+ width: 'fit-content',
59
+ maxWidth: '100%',
60
+ padding: 5,
61
+ overflow: 'scroll',
62
+ },
63
+ table: (theme) => ({
64
+ display: 'table',
65
+ width: '100%',
66
+ borderSpacing: '2px',
67
+ borderCollapse: 'collapse',
68
+ marginTop: theme.spacings.md,
69
+ marginBottom: theme.spacings.sm,
70
+ '& thead, tbody': {
71
+ '& td': {
72
+ padding: '10px 20px',
73
+ },
74
+ },
75
+ '& thead': {
76
+ '& tr': {
77
+ '& td': {
78
+ '& p': {
79
+ fontWeight: theme.typography.fontWeightBold,
80
+ },
81
+ },
82
+ },
83
+ },
84
+ '& tbody': {
85
+ display: 'table-row-group',
86
+ verticalAlign: 'center',
87
+ borderColor: 'inherit',
88
+ '& tr': {
89
+ '&:nth-of-type(odd)': {
90
+ background: theme.palette.background.paper,
91
+ },
92
+ },
93
+ '& td': {
94
+ [theme.breakpoints.up('sm')]: {
95
+ minWidth: '150px',
96
+ },
97
+ '& p': {},
98
+ },
99
+ },
100
+ }),
101
+ link: {
102
+ wordBreak: 'break-word',
103
+ },
104
+ underlined: {
105
+ textDecoration: 'underline',
106
+ },
107
+ iframe: {},
108
+ }
@@ -0,0 +1,11 @@
1
+ import { isElementNode, isTextNode } from './RichText'
2
+ import type { ElementOrTextNode } from './types'
3
+
4
+ export function getNodeLength(node: ElementOrTextNode): number {
5
+ if (isElementNode(node))
6
+ return node.children.map(getNodeLength).reduce<number>((prev, curr) => prev + curr, 0)
7
+
8
+ if (isTextNode(node)) return node.text.length
9
+
10
+ return 0
11
+ }
@@ -0,0 +1,2 @@
1
+ export * from './RichText'
2
+ export * from './getNodeLength'
@@ -0,0 +1,117 @@
1
+ import type { SxProps, Theme } from '@mui/material'
2
+ import type { LiteralUnion } from 'type-fest'
3
+
4
+ type BaseElementTypes =
5
+ | 'heading-one'
6
+ | 'heading-two'
7
+ | 'heading-three'
8
+ | 'heading-four'
9
+ | 'heading-five'
10
+ | 'heading-six'
11
+ | 'paragraph'
12
+ | 'numbered-list'
13
+ | 'bulleted-list'
14
+ | 'block-quote'
15
+ | 'list-item'
16
+ | 'list-item-child'
17
+ | 'table'
18
+ | 'table_head'
19
+ | 'table_header_cell'
20
+ | 'table_body'
21
+ | 'table_row'
22
+ | 'table_cell'
23
+ | 'code'
24
+ | 'bold'
25
+ | 'italic'
26
+ | 'underlined'
27
+
28
+ export type SimpleElement = {
29
+ children: ElementOrTextNode[]
30
+ type: LiteralUnion<BaseElementTypes, string>
31
+ }
32
+
33
+ export type TextNode = {
34
+ text: string
35
+ bold?: true
36
+ italic?: true
37
+ underlined?: true
38
+ [key: string]: unknown
39
+ }
40
+
41
+ type LinkElement = {
42
+ type: 'link'
43
+ children: ElementOrTextNode[]
44
+ href: string
45
+ openInNewTab?: boolean
46
+ }
47
+
48
+ type ClassElement = {
49
+ type: 'class'
50
+ children: ElementOrTextNode[]
51
+ className: string
52
+ }
53
+
54
+ type ImageElement = {
55
+ type: 'image'
56
+ children: ElementOrTextNode[]
57
+ src: string
58
+ title: string
59
+ altText: string
60
+ width: number
61
+ height: number
62
+ mimeType: string
63
+ }
64
+
65
+ type VideoElement = {
66
+ type: 'image'
67
+ children: ElementOrTextNode[]
68
+ src: string
69
+ title: string
70
+ width: number
71
+ height: number
72
+ mimeType: string
73
+ }
74
+
75
+ type IframeElement = {
76
+ type: 'iframe'
77
+ children: ElementOrTextNode[]
78
+ url: string
79
+ width?: number
80
+ height?: number
81
+ }
82
+
83
+ export type ElementNode =
84
+ | SimpleElement
85
+ | LinkElement
86
+ | ImageElement
87
+ | VideoElement
88
+ | IframeElement
89
+ | ClassElement
90
+ export type ElementOrTextNode = ElementNode | TextNode
91
+
92
+ type RendererBase = { sx?: SxProps<Theme>; children?: React.ReactNode }
93
+ export type Renderer<P extends ElementNode> = (
94
+ props: Omit<P, 'children' | 'type'> & RendererBase,
95
+ ) => React.ReactElement | null
96
+
97
+ export type Renderers = {
98
+ [k in BaseElementTypes]: Renderer<SimpleElement>
99
+ } & {
100
+ link: Renderer<LinkElement>
101
+ image: Renderer<ImageElement>
102
+ video: Renderer<VideoElement>
103
+ iframe: Renderer<IframeElement>
104
+ class: Renderer<ClassElement>
105
+ }
106
+
107
+ export type SxRenderer = {
108
+ [k in keyof Renderers | 'all' | 'first' | 'last']?: SxProps<Theme>
109
+ }
110
+
111
+ export type AdditionalProps = {
112
+ renderers: Renderers
113
+ sxRenderer: SxRenderer
114
+ first?: boolean
115
+ last?: boolean
116
+ className?: string
117
+ }
@@ -0,0 +1,2 @@
1
+ export * from './RichText'
2
+ export * from './Asset/Asset'
@@ -0,0 +1,11 @@
1
+ query HygraphAllPages($first: Int = 100, $skip: Int) {
2
+ pages(first: $first, skip: $skip) {
3
+ url
4
+ }
5
+
6
+ pagesConnection {
7
+ aggregate {
8
+ count
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,16 @@
1
+ fragment HygraphPage on Page {
2
+ title
3
+ metaTitle
4
+ metaDescription
5
+ metaRobots
6
+ url
7
+ author
8
+ date
9
+ relatedPages {
10
+ title
11
+ url
12
+ }
13
+ asset {
14
+ ...Asset
15
+ }
16
+ }
@@ -0,0 +1,7 @@
1
+ query HygraphPages($url: String!) {
2
+ pages(where: { url: $url }) {
3
+ id
4
+ __typename
5
+ ...HygraphPage
6
+ }
7
+ }
@@ -0,0 +1,11 @@
1
+ query HygraphStaticPaths($pageSize: Int!, $skip: Int, $where: PageWhereInput) {
2
+ pages(first: $pageSize, skip: $skip, where: $where) {
3
+ url
4
+ }
5
+
6
+ pagesConnection {
7
+ aggregate {
8
+ count
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,10 @@
1
+ fragment PageLink on PageLink {
2
+ title
3
+ url
4
+ description {
5
+ raw
6
+ }
7
+ asset {
8
+ ...Asset
9
+ }
10
+ }
@@ -0,0 +1,6 @@
1
+ query PagesStaticPaths($urlStartsWith: String!, $first: Int = 1000) {
2
+ pages(where: { url_starts_with: $urlStartsWith }, first: $first) {
3
+ url
4
+ metaRobots
5
+ }
6
+ }
@@ -0,0 +1,5 @@
1
+ export * from './HygraphAllPages.gql'
2
+ export * from './HygraphPage.gql'
3
+ export * from './HygraphPages.gql'
4
+ export * from './PageLink.gql'
5
+ export * from './PagesStaticPaths.gql'
package/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './components'
2
+ export * from './graphql'
3
+ export * from './lib'
@@ -0,0 +1,44 @@
1
+ import type { ApolloClient, NormalizedCacheObject, ApolloQueryResult } from '@apollo/client'
2
+ import { cacheFirst } from '@graphcommerce/graphql'
3
+ import type { HygraphAllPagesQuery } from '../graphql'
4
+ import { HygraphAllPagesDocument } from '../graphql'
5
+
6
+ type Urls = { url: string }
7
+
8
+ export async function getAllHygraphPages(
9
+ client: ApolloClient<NormalizedCacheObject>,
10
+ options: { pageSize?: number } = {},
11
+ ) {
12
+ const { pageSize = 100 } = options
13
+
14
+ const query = client.query({
15
+ query: HygraphAllPagesDocument,
16
+ variables: { first: pageSize },
17
+ fetchPolicy: cacheFirst(client),
18
+ })
19
+ const pages: Promise<ApolloQueryResult<HygraphAllPagesQuery>>[] = [query]
20
+
21
+ const { data } = await query
22
+ const totalPages = Math.ceil(data.pagesConnection.aggregate.count / pageSize) ?? 1
23
+
24
+ if (totalPages > 1) {
25
+ for (let i = 2; i <= totalPages; i++) {
26
+ pages.push(
27
+ client.query({
28
+ query: HygraphAllPagesDocument,
29
+ variables: { first: pageSize, skip: pageSize * (i - 1) },
30
+ fetchPolicy: cacheFirst(client),
31
+ }),
32
+ )
33
+ }
34
+ }
35
+
36
+ const paths: Urls[] = (await Promise.all(pages))
37
+ .map((q) => q.data.pages)
38
+ .flat(1)
39
+ .map((page) => ({
40
+ url: page.url,
41
+ }))
42
+
43
+ return paths
44
+ }
@@ -0,0 +1,41 @@
1
+ import type { ApolloClient, NormalizedCacheObject, ApolloQueryResult } from '@apollo/client'
2
+ import type { PageWhereInput } from '@graphcommerce/graphql-mesh'
3
+ import type { GetStaticPathsResult } from 'next'
4
+ import type { HygraphStaticPathsQuery } from '../graphql/HygraphStaticPaths.gql'
5
+ import { HygraphStaticPathsDocument } from '../graphql/HygraphStaticPaths.gql'
6
+
7
+ type Return = GetStaticPathsResult<{ url: string }>
8
+
9
+ export async function getHygraphStaticPaths(
10
+ client: ApolloClient<NormalizedCacheObject>,
11
+ locale: string,
12
+ options: { pageSize?: number; filter?: PageWhereInput } = {},
13
+ ) {
14
+ const { pageSize = 100, filter = {} } = options
15
+ const query = client.query({
16
+ query: HygraphStaticPathsDocument,
17
+ variables: { pageSize, where: filter },
18
+ })
19
+ const pages: Promise<ApolloQueryResult<HygraphStaticPathsQuery>>[] = [query]
20
+
21
+ const { data } = await query
22
+ const totalPages = Math.ceil(data.pagesConnection.aggregate.count / pageSize) ?? 1
23
+
24
+ if (totalPages > 1) {
25
+ for (let i = 2; i <= totalPages; i++) {
26
+ pages.push(
27
+ client.query({
28
+ query: HygraphStaticPathsDocument,
29
+ variables: { pageSize, skip: pageSize * (i - 1), where: filter },
30
+ }),
31
+ )
32
+ }
33
+ }
34
+
35
+ const paths: Return['paths'] = (await Promise.all(pages))
36
+ .map((q) => q.data.pages)
37
+ .flat(1)
38
+ .map((page) => ({ params: page, locale }))
39
+
40
+ return paths
41
+ }
@@ -0,0 +1,49 @@
1
+ import type { ApolloClient, NormalizedCacheObject } from '@graphcommerce/graphql'
2
+ import type { HygraphPagesQuery } from '../graphql'
3
+ import { HygraphPagesDocument } from '../graphql'
4
+ import { getAllHygraphPages } from './getAllHygraphPages'
5
+
6
+ /**
7
+ * Fetch the page content for the given urls.
8
+ *
9
+ * - Uses an early bailout to check to reduce hygraph calls.
10
+ * - Implements an alias sytem to merge the content of multiple pages.
11
+ */
12
+ async function pageContent(
13
+ client: ApolloClient<NormalizedCacheObject>,
14
+ url: string,
15
+ cached: boolean,
16
+ ): Promise<{ data: HygraphPagesQuery }> {
17
+ /**
18
+ * Some routes are very generic and wil be requested very often, like 'product/global'. To reduce
19
+ * the amount of requests to Hygraph we can cache the result of the query if requested.
20
+ *
21
+ * This only works in a persistent nodejs environment and doesn't work in a serverless
22
+ * environment, because those instances get discarded.
23
+ *
24
+ * This comes with a downside, if the page is updated the cache will not be invalidated, resulting
25
+ * in stale data.
26
+ *
27
+ * Todo: Implement next.js 13 fetch revalidation:
28
+ * https://beta.nextjs.org/docs/data-fetching/fetching#revalidating-data
29
+ */
30
+ const alwaysCache = process.env.NODE_ENV !== 'development' ? 'cache-first' : undefined
31
+ const fetchPolicy = cached ? alwaysCache : undefined
32
+
33
+ const allRoutes = await getAllHygraphPages(client)
34
+ // Only do the query when there the page is found in the allRoutes
35
+ const found = allRoutes.some((page) => page.url === url)
36
+
37
+ return found
38
+ ? client.query({ query: HygraphPagesDocument, variables: { url }, fetchPolicy })
39
+ : Promise.resolve({ data: { pages: [] } })
40
+ }
41
+
42
+ export async function hygraphPageContent(
43
+ client: ApolloClient<NormalizedCacheObject>,
44
+ url: string,
45
+ additionalProperties?: Promise<object> | object,
46
+ cached = false,
47
+ ): Promise<{ data: HygraphPagesQuery }> {
48
+ return pageContent(client, url, cached)
49
+ }
package/lib/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './hygraphPageContent'
2
+ export * from './getHygraphPaths'
package/next-env.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/types/global" />
3
+ /// <reference types="next/image-types/global" />
4
+ /// <reference types="@graphcommerce/next-ui/types" />
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@graphcommerce/hygraph-ui",
3
+ "homepage": "https://www.graphcommerce.org/",
4
+ "repository": "github:graphcommerce-org/graphcommerce",
5
+ "version": "9.0.0-canary.103",
6
+ "sideEffects": false,
7
+ "prettier": "@graphcommerce/prettier-config-pwa",
8
+ "eslintConfig": {
9
+ "extends": "@graphcommerce/eslint-config-pwa",
10
+ "parserOptions": {
11
+ "project": "./tsconfig.json"
12
+ }
13
+ },
14
+ "peerDependencies": {
15
+ "@graphcommerce/ecommerce-ui": "^9.0.0-canary.103",
16
+ "@graphcommerce/eslint-config-pwa": "^9.0.0-canary.103",
17
+ "@graphcommerce/graphql": "^9.0.0-canary.103",
18
+ "@graphcommerce/image": "^9.0.0-canary.103",
19
+ "@graphcommerce/next-ui": "^9.0.0-canary.103",
20
+ "@graphcommerce/prettier-config-pwa": "^9.0.0-canary.103",
21
+ "@graphcommerce/typescript-config-pwa": "^9.0.0-canary.103",
22
+ "@mui/material": "^5.10.16",
23
+ "next": "*",
24
+ "react": "^18.2.0",
25
+ "react-dom": "^18.2.0"
26
+ }
27
+ }
@@ -0,0 +1,84 @@
1
+ import {
2
+ usePreviewModeForm,
3
+ type PreviewModeToolbarProps,
4
+ SelectElement,
5
+ previewModeDefaults,
6
+ useWatch,
7
+ } from '@graphcommerce/ecommerce-ui'
8
+ import type { TypedDocumentNode } from '@graphcommerce/graphql'
9
+ import { gql, useQuery } from '@graphcommerce/graphql'
10
+ import type { PluginConfig, PluginProps } from '@graphcommerce/next-config'
11
+ import { filterNonNullableKeys } from '@graphcommerce/next-ui'
12
+ import React, { useMemo } from 'react'
13
+
14
+ export const config: PluginConfig = {
15
+ type: 'component',
16
+ module: '@graphcommerce/ecommerce-ui',
17
+ }
18
+
19
+ const ContentStages = gql`
20
+ query {
21
+ __type(name: "Stage") {
22
+ name
23
+ enumValues {
24
+ name
25
+ description
26
+ }
27
+ }
28
+ }
29
+ ` as TypedDocumentNode<{
30
+ __type: {
31
+ name: 'Stage'
32
+ enumValues: {
33
+ name: string
34
+ description: string
35
+ }[]
36
+ }
37
+ }>
38
+
39
+ const HygraphConfig = React.memo(() => {
40
+ const form = usePreviewModeForm()
41
+ const { control } = form
42
+
43
+ const contentStages = useQuery(ContentStages)
44
+
45
+ const defaultValue =
46
+ useWatch({ control, name: 'previewData.hygraphStage' }) ??
47
+ previewModeDefaults().hygraphStage ??
48
+ 'PUBLISHED'
49
+
50
+ return useMemo(
51
+ () => (
52
+ <SelectElement
53
+ control={control}
54
+ name='previewData.hygraphStage'
55
+ color='secondary'
56
+ defaultValue={defaultValue}
57
+ label='Hygraph Stage'
58
+ size='small'
59
+ sx={{ width: '150px' }}
60
+ SelectProps={{ MenuProps: { style: { zIndex: 20000 } } }}
61
+ onChange={() => {}}
62
+ options={
63
+ contentStages.loading
64
+ ? [{ id: defaultValue, label: defaultValue }]
65
+ : filterNonNullableKeys(contentStages.data?.__type.enumValues).map(({ name }) => ({
66
+ id: name,
67
+ label: name,
68
+ }))
69
+ }
70
+ />
71
+ ),
72
+ [contentStages.data?.__type.enumValues, contentStages.loading, control, defaultValue],
73
+ )
74
+ })
75
+
76
+ export function PreviewModeToolbar(props: PluginProps<PreviewModeToolbarProps>) {
77
+ const { Prev, ...rest } = props
78
+ return (
79
+ <>
80
+ <Prev {...rest} />
81
+ <HygraphConfig />
82
+ </>
83
+ )
84
+ }
@@ -0,0 +1,36 @@
1
+ import type { graphqlConfig as graphqlConfigType } from '@graphcommerce/graphql'
2
+ import { setContext } from '@graphcommerce/graphql'
3
+ import type { FunctionPlugin, PluginConfig } from '@graphcommerce/next-config'
4
+
5
+ export const config: PluginConfig = {
6
+ type: 'function',
7
+ module: '@graphcommerce/graphql',
8
+ }
9
+
10
+ declare module '@graphcommerce/graphql/config' {
11
+ interface PreviewData {
12
+ hygraphStage?: string
13
+ }
14
+ }
15
+
16
+ export const graphqlConfig: FunctionPlugin<typeof graphqlConfigType> = (prev, conf) => {
17
+ const results = prev(conf)
18
+
19
+ const locales = conf.storefront.hygraphLocales
20
+
21
+ if (!locales) return prev(conf)
22
+
23
+ const hygraphLink = setContext((_, context) => {
24
+ if (!context.headers) context.headers = {}
25
+ context.headers['gcms-locales'] = locales.join(',')
26
+
27
+ const stage = conf.previewData?.hygraphStage ?? 'DRAFT'
28
+ if (conf.preview) {
29
+ context.headers['gcms-stage'] = stage
30
+ }
31
+
32
+ return context
33
+ })
34
+
35
+ return { ...results, links: [...results.links, hygraphLink] }
36
+ }
@@ -0,0 +1,12 @@
1
+ import type { previewModeDefaults as base } from '@graphcommerce/ecommerce-ui'
2
+ import type { FunctionPlugin, PluginConfig } from '@graphcommerce/next-config'
3
+
4
+ export const config: PluginConfig = {
5
+ type: 'function',
6
+ module: '@graphcommerce/ecommerce-ui',
7
+ }
8
+
9
+ export const previewModeDefaults: FunctionPlugin<typeof base> = (prev, ...args) => ({
10
+ ...prev(...args),
11
+ hygraphStage: 'DRAFT',
12
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "exclude": ["**/node_modules", "**/.*/"],
3
+ "include": ["**/*.ts", "**/*.tsx"],
4
+ "extends": "@graphcommerce/typescript-config-pwa/nextjs.json"
5
+ }