@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 +7 -0
- package/Config.graphqls +17 -0
- package/README.md +4 -0
- package/components/Asset/Asset.graphql +7 -0
- package/components/Asset/Asset.tsx +61 -0
- package/components/RichText/RichText.tsx +199 -0
- package/components/RichText/defaultRenderers.tsx +58 -0
- package/components/RichText/defaultSxRenderer.ts +108 -0
- package/components/RichText/getNodeLength.tsx +11 -0
- package/components/RichText/index.ts +2 -0
- package/components/RichText/types.ts +117 -0
- package/components/index.ts +2 -0
- package/graphql/HygraphAllPages.graphql +11 -0
- package/graphql/HygraphPage.graphql +16 -0
- package/graphql/HygraphPages.graphql +7 -0
- package/graphql/HygraphStaticPaths.graphql +11 -0
- package/graphql/PageLink.graphql +10 -0
- package/graphql/PagesStaticPaths.graphql +6 -0
- package/graphql/index.ts +5 -0
- package/index.ts +3 -0
- package/lib/getAllHygraphPages.ts +44 -0
- package/lib/getHygraphPaths.ts +41 -0
- package/lib/hygraphPageContent.ts +49 -0
- package/lib/index.ts +2 -0
- package/next-env.d.ts +4 -0
- package/package.json +27 -0
- package/plugins/HygraphPreviewModeToolbar.tsx +84 -0
- package/plugins/hygraphGraphqlConfig.ts +36 -0
- package/plugins/hygraphPreviewModeDefaults.ts +12 -0
- package/tsconfig.json +5 -0
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))
|
package/Config.graphqls
ADDED
|
@@ -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,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,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
|
+
}
|
package/graphql/index.ts
ADDED
package/index.ts
ADDED
|
@@ -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
package/next-env.d.ts
ADDED
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
|
+
})
|