@graphcommerce/storyblok-ui 10.1.0-canary.4
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 +37 -0
- package/Config.graphqls +32 -0
- package/components/Asset.tsx +72 -0
- package/components/RichText/RichText.tsx +176 -0
- package/components/RichText/defaultRenderers.tsx +86 -0
- package/components/RichText/defaultSxRenderer.ts +69 -0
- package/components/RichText/index.ts +4 -0
- package/components/RichText/types.ts +127 -0
- package/components/index.ts +2 -0
- package/index.ts +4 -0
- package/lib/editable.ts +12 -0
- package/lib/fetch.ts +180 -0
- package/lib/getStoryblokStaticPaths.ts +39 -0
- package/lib/index.ts +7 -0
- package/lib/multilinkHref.ts +28 -0
- package/lib/resolveProducts.ts +88 -0
- package/lib/usePreventEditorNavigation.ts +27 -0
- package/lib/useStoryblokState.ts +68 -0
- package/package.json +30 -0
- package/tsconfig.json +5 -0
- package/types.ts +56 -0
- package/utils.ts +28 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @graphcommerce/storyblok-ui
|
|
2
|
+
|
|
3
|
+
## 10.1.0-canary.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#2606](https://github.com/graphcommerce-org/graphcommerce/pull/2606) [`d30c568`](https://github.com/graphcommerce-org/graphcommerce/commit/d30c5681e018496ce44c349f69b122d143e894d2) - Performance improvements for Storyblok bridge ([@bramvanderholst](https://github.com/bramvanderholst))
|
|
8
|
+
|
|
9
|
+
- [#2606](https://github.com/graphcommerce-org/graphcommerce/pull/2606) [`d30c568`](https://github.com/graphcommerce-org/graphcommerce/commit/d30c5681e018496ce44c349f69b122d143e894d2) - Added Storyblok pages to content sitemap ([@bramvanderholst](https://github.com/bramvanderholst))
|
|
10
|
+
|
|
11
|
+
## 10.1.0-canary.3
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- [#2603](https://github.com/graphcommerce-org/graphcommerce/pull/2603) [`7198061`](https://github.com/graphcommerce-org/graphcommerce/commit/71980613241a240fa29ba9894872b90c3d32c2b6) - ## Storyblok example
|
|
16
|
+
|
|
17
|
+
We're shipping `examples/magento-storyblok` — a full Storyblok CMS integration alongside the existing Hygraph example, so teams can pick the headless CMS that fits their workflow.
|
|
18
|
+
|
|
19
|
+
**What's in the box**
|
|
20
|
+
|
|
21
|
+
- Visual editing: live preview, click-to-edit on every blok, and SSR with client hydration
|
|
22
|
+
- Row components out of the box: hero banners, special banners, quotes, button link lists, service options, product grids, blog content, and USPs — all editable in the Storyblok Visual Editor
|
|
23
|
+
- Global config pattern for content shared across pages (footer links, social links, USPs) with live updates in the editor
|
|
24
|
+
- Type-safe integration: generated TypeScript types for every component schema via the Storyblok CLI
|
|
25
|
+
- `@graphcommerce/storyblok-ui` helpers: rich text renderer, asset component with video/image support, multilink resolution, product block resolution, and Visual Editor utilities
|
|
26
|
+
|
|
27
|
+
**Getting started**
|
|
28
|
+
|
|
29
|
+
- `yarn storyblok:bootstrap` seeds a new Storyblok space with all demo content (components, stories, assets) so a fresh install is editable in minutes
|
|
30
|
+
- `spaceId` lives in `graphcommerce.config.cjs` alongside your other config — no separate env juggling
|
|
31
|
+
- Components, stories, and assets are committed to the repo as the source of truth
|
|
32
|
+
|
|
33
|
+
**Also includes**
|
|
34
|
+
|
|
35
|
+
- Storyblok-aware Footer, Navigation, and PDP layouts wired to the global config
|
|
36
|
+
- Blog support with tag filtering and pagination
|
|
37
|
+
- Preview URL routing so the Visual Editor opens the right page for each story type ([@bramvanderholst](https://github.com/bramvanderholst))
|
package/Config.graphqls
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Settings for the Storyblok integration.
|
|
3
|
+
"""
|
|
4
|
+
input StoryblokConfig {
|
|
5
|
+
"""
|
|
6
|
+
Your project's Storyblok space ID. Used as the target space for `storyblok:bootstrap`
|
|
7
|
+
(seeding a new space with the example content) and as the default space for pull/push commands.
|
|
8
|
+
"""
|
|
9
|
+
spaceId: String!
|
|
10
|
+
|
|
11
|
+
"""
|
|
12
|
+
Public preview access token for the Storyblok Content Delivery API. Found under
|
|
13
|
+
Settings → Access Tokens in the Storyblok space. Inlined into the client bundle —
|
|
14
|
+
do not use a private/management token here. Override per-environment with
|
|
15
|
+
`GC_STORYBLOK_ACCESS_TOKEN`.
|
|
16
|
+
"""
|
|
17
|
+
accessToken: String!
|
|
18
|
+
|
|
19
|
+
"""
|
|
20
|
+
Source Storyblok space ID to bootstrap from. Defaults to the GraphCommerce
|
|
21
|
+
example space (291439709879423) which contains demo content for this template.
|
|
22
|
+
Override only if you maintain your own example/template space.
|
|
23
|
+
"""
|
|
24
|
+
sourceSpaceId: String
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
extend input GraphCommerceConfig {
|
|
28
|
+
"""
|
|
29
|
+
Settings for the Storyblok integration.
|
|
30
|
+
"""
|
|
31
|
+
storyblok: StoryblokConfig!
|
|
32
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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 { StoryblokAssetData } from '../types'
|
|
7
|
+
import { isSvg, isVideo, parseDimensions } from '../utils'
|
|
8
|
+
|
|
9
|
+
export type AssetProps = {
|
|
10
|
+
asset: StoryblokAssetData
|
|
11
|
+
sx?: SxProps<Theme>
|
|
12
|
+
} & Omit<ImageProps, 'src' | 'width' | 'height' | 'alt' | 'sx'>
|
|
13
|
+
|
|
14
|
+
const Video = styled('video')({})
|
|
15
|
+
|
|
16
|
+
// Storyblok encodes image dimensions in the CDN URL (`/WxH/`), but its image
|
|
17
|
+
// processor only runs for uploads made through the web UI. Assets uploaded via
|
|
18
|
+
// the Management API — which is what the CLI's `assets push` (used by our
|
|
19
|
+
// bootstrap script) does — never get that segment, so `parseDimensions`
|
|
20
|
+
// returns null for them. next/image requires non-zero width/height or it
|
|
21
|
+
// emits a 1w placeholder in srcset that some browsers pick inside the
|
|
22
|
+
// Storyblok Visual Editor iframe, leaving images blank. These fallbacks
|
|
23
|
+
// satisfy next/image; the rendered size is controlled by CSS (sx).
|
|
24
|
+
//
|
|
25
|
+
// In practice this only affects the bootstrapped demo content — assets
|
|
26
|
+
// editors upload through the Storyblok UI get proper dimensions in the URL
|
|
27
|
+
// and hit the `parseDimensions` path above.
|
|
28
|
+
const FALLBACK_WIDTH = 500
|
|
29
|
+
const FALLBACK_HEIGHT = 500
|
|
30
|
+
|
|
31
|
+
function AssetBase(props: AssetProps) {
|
|
32
|
+
const { asset, sx = [], ...imgProps } = props
|
|
33
|
+
|
|
34
|
+
if (!asset.filename) return null
|
|
35
|
+
|
|
36
|
+
if (isVideo(asset.filename)) {
|
|
37
|
+
return (
|
|
38
|
+
<Video src={asset.filename} autoPlay muted loop playsInline disableRemotePlayback sx={sx} />
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const dimensions = parseDimensions(asset.filename)
|
|
43
|
+
|
|
44
|
+
if (isSvg(asset.filename)) {
|
|
45
|
+
return (
|
|
46
|
+
<Image
|
|
47
|
+
src={asset.filename}
|
|
48
|
+
width={dimensions?.width ?? 0}
|
|
49
|
+
height={dimensions?.height ?? 0}
|
|
50
|
+
alt={asset.alt ?? ''}
|
|
51
|
+
unoptimized
|
|
52
|
+
{...imgProps}
|
|
53
|
+
sx={
|
|
54
|
+
dimensions ? sx : [{ width: '100%', height: 'auto' }, ...(Array.isArray(sx) ? sx : [sx])]
|
|
55
|
+
}
|
|
56
|
+
/>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Image
|
|
62
|
+
src={asset.filename}
|
|
63
|
+
width={dimensions?.width ?? FALLBACK_WIDTH}
|
|
64
|
+
height={dimensions?.height ?? FALLBACK_HEIGHT}
|
|
65
|
+
alt={asset.alt ?? ''}
|
|
66
|
+
{...imgProps}
|
|
67
|
+
sx={sx}
|
|
68
|
+
/>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const Asset = memo(AssetBase)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { SxProps, Theme } from '@mui/material'
|
|
2
|
+
import { Fragment, type ReactElement, type ReactNode } from 'react'
|
|
3
|
+
import type { StoryblokRichtextData } from '../../types'
|
|
4
|
+
import { defaultRenderers } from './defaultRenderers'
|
|
5
|
+
import { defaultSxRenderer } from './defaultSxRenderer'
|
|
6
|
+
import type { AdditionalProps, Renderer, Renderers, RichTextNodeType, SxRenderer } from './types'
|
|
7
|
+
|
|
8
|
+
const sxArr = (sx?: SxProps<Theme> | false): SxProps<Theme>[] => {
|
|
9
|
+
if (!sx) return []
|
|
10
|
+
return Array.isArray(sx) ? (sx as SxProps<Theme>[]) : [sx]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function computeSx(
|
|
14
|
+
{ first, last, sxRenderer }: Pick<AdditionalProps, 'first' | 'last' | 'sxRenderer'>,
|
|
15
|
+
type: RichTextNodeType,
|
|
16
|
+
): SxProps<Theme> {
|
|
17
|
+
return [
|
|
18
|
+
...sxArr(sxRenderer.all),
|
|
19
|
+
...sxArr(sxRenderer[type]),
|
|
20
|
+
...sxArr(first && sxRenderer.first),
|
|
21
|
+
...sxArr(last && sxRenderer.last),
|
|
22
|
+
] as SxProps<Theme>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveType(node: StoryblokRichtextData): RichTextNodeType | null {
|
|
26
|
+
if (node.type === 'heading') {
|
|
27
|
+
const level = (node.attrs?.level as number | undefined) ?? 1
|
|
28
|
+
return `h${level}` as RichTextNodeType
|
|
29
|
+
}
|
|
30
|
+
return node.type as RichTextNodeType
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function RenderText(props: { node: StoryblokRichtextData } & AdditionalProps): ReactNode {
|
|
34
|
+
const { node, renderers, sxRenderer, first, last } = props
|
|
35
|
+
const text = node.text ?? ''
|
|
36
|
+
|
|
37
|
+
const parts = text.split('\n')
|
|
38
|
+
const withBreaks: ReactNode =
|
|
39
|
+
parts.length === 1 ? (
|
|
40
|
+
text
|
|
41
|
+
) : (
|
|
42
|
+
<>
|
|
43
|
+
{parts.map((part, i) => (
|
|
44
|
+
<Fragment key={i}>
|
|
45
|
+
{part}
|
|
46
|
+
{i < parts.length - 1 ? <br /> : null}
|
|
47
|
+
</Fragment>
|
|
48
|
+
))}
|
|
49
|
+
</>
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const marks = node.marks ?? []
|
|
53
|
+
return marks.reduce<ReactNode>((child, mark) => {
|
|
54
|
+
const type = mark.type as RichTextNodeType
|
|
55
|
+
const MarkRenderer = renderers[type] as Renderer<Record<string, unknown>> | undefined
|
|
56
|
+
if (!MarkRenderer) {
|
|
57
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
58
|
+
console.error(mark)
|
|
59
|
+
throw new Error(`RichText: Unknown mark type: ${mark.type}`)
|
|
60
|
+
}
|
|
61
|
+
return child
|
|
62
|
+
}
|
|
63
|
+
const sx = computeSx({ first, last, sxRenderer }, type)
|
|
64
|
+
const attrs = (mark.attrs ?? {}) as Record<string, unknown>
|
|
65
|
+
return (
|
|
66
|
+
<MarkRenderer sx={sx} {...attrs}>
|
|
67
|
+
{child}
|
|
68
|
+
</MarkRenderer>
|
|
69
|
+
)
|
|
70
|
+
}, withBreaks)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function RenderNode(props: { node: StoryblokRichtextData } & AdditionalProps): ReactNode {
|
|
74
|
+
const { node } = props
|
|
75
|
+
if (node.type === 'text') return <RenderText {...props} />
|
|
76
|
+
|
|
77
|
+
const { renderers, sxRenderer, first, last } = props
|
|
78
|
+
const type = resolveType(node)
|
|
79
|
+
if (!type) return null
|
|
80
|
+
|
|
81
|
+
const NodeRenderer = renderers[type] as Renderer<Record<string, unknown>> | undefined
|
|
82
|
+
if (!NodeRenderer) {
|
|
83
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
84
|
+
console.error(node)
|
|
85
|
+
throw new Error(`RichText: Unknown node type: ${node.type}`)
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sx = computeSx({ first, last, sxRenderer }, type)
|
|
91
|
+
const attrs = (node.attrs ?? {}) as Record<string, unknown>
|
|
92
|
+
const children = node.content ? (
|
|
93
|
+
<RenderChildren childNodes={node.content} renderers={renderers} sxRenderer={sxRenderer} />
|
|
94
|
+
) : null
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<NodeRenderer sx={sx} {...attrs}>
|
|
98
|
+
{children}
|
|
99
|
+
</NodeRenderer>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function RenderChildren(
|
|
104
|
+
props: { childNodes: StoryblokRichtextData[]; noMargin?: boolean } & Pick<
|
|
105
|
+
AdditionalProps,
|
|
106
|
+
'renderers' | 'sxRenderer'
|
|
107
|
+
>,
|
|
108
|
+
): ReactElement {
|
|
109
|
+
const { childNodes, noMargin, renderers, sxRenderer } = props
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
{childNodes.map((node, key) => (
|
|
113
|
+
<RenderNode
|
|
114
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
115
|
+
key={key}
|
|
116
|
+
node={node}
|
|
117
|
+
renderers={renderers}
|
|
118
|
+
sxRenderer={sxRenderer}
|
|
119
|
+
first={noMargin && key === 0}
|
|
120
|
+
last={noMargin && key === childNodes.length - 1}
|
|
121
|
+
/>
|
|
122
|
+
))}
|
|
123
|
+
</>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function mergeSxRenderer(base: SxRenderer, override?: SxRenderer): SxRenderer {
|
|
128
|
+
if (!override) return base
|
|
129
|
+
const keys = new Set([...Object.keys(base), ...Object.keys(override)])
|
|
130
|
+
const result: SxRenderer = {}
|
|
131
|
+
for (const key of keys) {
|
|
132
|
+
const k = key as keyof SxRenderer
|
|
133
|
+
const baseSx = base[k]
|
|
134
|
+
const overrideSx = override[k]
|
|
135
|
+
if (baseSx && overrideSx) {
|
|
136
|
+
result[k] = [...sxArr(baseSx), ...sxArr(overrideSx)] as SxProps<Theme>
|
|
137
|
+
} else {
|
|
138
|
+
result[k] = baseSx ?? overrideSx
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return result
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type RichTextProps = {
|
|
145
|
+
content: StoryblokRichtextData
|
|
146
|
+
renderers?: Partial<Renderers>
|
|
147
|
+
/**
|
|
148
|
+
* Per-element theming. Keys are Storyblok node/mark types (e.g. `paragraph`, `h1`,
|
|
149
|
+
* `link`, `bold`). Merges with the built-in defaults.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```tsx
|
|
153
|
+
* <RichText
|
|
154
|
+
* content={blok.copy}
|
|
155
|
+
* sxRenderer={{
|
|
156
|
+
* paragraph: (theme) => ({ columnGap: theme.spacings.md }),
|
|
157
|
+
* }}
|
|
158
|
+
* />
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
sxRenderer?: SxRenderer
|
|
162
|
+
/** When true, preserves the first/last element's outer margins. Defaults to false (stripped). */
|
|
163
|
+
withMargin?: boolean
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function RichText({ content, renderers, sxRenderer, withMargin = false }: RichTextProps) {
|
|
167
|
+
if (!content?.content) return null
|
|
168
|
+
return (
|
|
169
|
+
<RenderChildren
|
|
170
|
+
childNodes={content.content}
|
|
171
|
+
renderers={{ ...defaultRenderers, ...renderers }}
|
|
172
|
+
sxRenderer={mergeSxRenderer(defaultSxRenderer, sxRenderer)}
|
|
173
|
+
noMargin={!withMargin}
|
|
174
|
+
/>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { sxx } from '@graphcommerce/next-ui'
|
|
2
|
+
import { Box, Link, Typography } from '@mui/material'
|
|
3
|
+
import { StoryblokComponent } from '@storyblok/react'
|
|
4
|
+
import { Asset } from '../Asset'
|
|
5
|
+
import type { Renderers } from './types'
|
|
6
|
+
|
|
7
|
+
export const defaultRenderers: Renderers = {
|
|
8
|
+
paragraph: (props) => <Typography variant='body1' gutterBottom {...props} />,
|
|
9
|
+
h1: (props) => <Typography variant='h1' {...props} />,
|
|
10
|
+
h2: (props) => <Typography variant='h2' {...props} />,
|
|
11
|
+
h3: (props) => <Typography variant='h3' {...props} />,
|
|
12
|
+
h4: (props) => <Typography variant='h4' {...props} />,
|
|
13
|
+
h5: (props) => <Typography variant='h5' {...props} />,
|
|
14
|
+
h6: (props) => <Typography variant='h6' {...props} />,
|
|
15
|
+
blockquote: (props) => <Box component='blockquote' {...props} />,
|
|
16
|
+
bullet_list: (props) => <Box component='ul' {...props} />,
|
|
17
|
+
ordered_list: (props) => <Box component='ol' {...props} />,
|
|
18
|
+
list_item: (props) => <Box component='li' {...props} />,
|
|
19
|
+
code_block: ({ class: _class, ...props }) => (
|
|
20
|
+
<Box component='pre' sx={props.sx}>
|
|
21
|
+
<Box component='code'>{props.children}</Box>
|
|
22
|
+
</Box>
|
|
23
|
+
),
|
|
24
|
+
horizontal_rule: (props) => <Box component='hr' {...props} />,
|
|
25
|
+
hard_break: () => <br />,
|
|
26
|
+
image: ({ src, alt, title, sx }) => (
|
|
27
|
+
<Box component='span' sx={sx}>
|
|
28
|
+
<Asset asset={{ filename: src ?? null, alt: alt ?? title ?? '' }} />
|
|
29
|
+
</Box>
|
|
30
|
+
),
|
|
31
|
+
emoji: ({ emoji, name, fallbackImage, sx }) => {
|
|
32
|
+
if (emoji) {
|
|
33
|
+
return (
|
|
34
|
+
<Box component='span' sx={sx}>
|
|
35
|
+
{emoji}
|
|
36
|
+
</Box>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
if (fallbackImage) {
|
|
40
|
+
return <Box component='img' src={fallbackImage} alt={name ?? ''} sx={sx} />
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
},
|
|
44
|
+
blok: ({ body, sx }) => (
|
|
45
|
+
<Box sx={sx}>
|
|
46
|
+
{body?.map((item) => <StoryblokComponent key={String(item._uid)} blok={item} />)}
|
|
47
|
+
</Box>
|
|
48
|
+
),
|
|
49
|
+
table: (props) => <Box component='table' {...props} />,
|
|
50
|
+
tableRow: (props) => <Box component='tr' {...props} />,
|
|
51
|
+
tableHeader: (props) => <Box component='th' {...props} />,
|
|
52
|
+
tableCell: (props) => <Box component='td' {...props} />,
|
|
53
|
+
bold: (props) => <Box component='strong' {...props} sx={sxx({ fontWeight: 'bold' }, props.sx)} />,
|
|
54
|
+
italic: (props) => <Box component='em' {...props} sx={sxx({ fontStyle: 'italic' }, props.sx)} />,
|
|
55
|
+
underline: (props) => <Box component='span' {...props} />,
|
|
56
|
+
strike: (props) => (
|
|
57
|
+
<Box component='s' {...props} sx={sxx({ textDecoration: 'line-through' }, props.sx)} />
|
|
58
|
+
),
|
|
59
|
+
code: (props) => <Box component='code' {...props} />,
|
|
60
|
+
link: ({ href, anchor, target, linktype, story, ...props }) => {
|
|
61
|
+
const base =
|
|
62
|
+
linktype === 'story' ? (story?.url ?? story?.full_slug ?? href ?? '') : (href ?? '')
|
|
63
|
+
const finalHref = anchor ? `${base}#${anchor}` : base
|
|
64
|
+
return (
|
|
65
|
+
<Link
|
|
66
|
+
href={finalHref}
|
|
67
|
+
underline='hover'
|
|
68
|
+
target={target || undefined}
|
|
69
|
+
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
},
|
|
74
|
+
anchor: ({ id, ...props }) => <Box component='span' id={id} {...props} />,
|
|
75
|
+
styled: ({ class: className, ...props }) => (
|
|
76
|
+
<Box component='span' className={className} {...props} />
|
|
77
|
+
),
|
|
78
|
+
superscript: (props) => <Box component='sup' {...props} />,
|
|
79
|
+
subscript: (props) => <Box component='sub' {...props} />,
|
|
80
|
+
textStyle: ({ color, sx, ...props }) => (
|
|
81
|
+
<Box component='span' sx={sxx(color ? { color } : {}, sx)} {...props} />
|
|
82
|
+
),
|
|
83
|
+
highlight: ({ color, sx, ...props }) => (
|
|
84
|
+
<Box component='mark' sx={sxx(color ? { backgroundColor: color } : {}, sx)} {...props} />
|
|
85
|
+
),
|
|
86
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SxRenderer } from './types'
|
|
2
|
+
|
|
3
|
+
export const defaultSxRenderer: SxRenderer = {
|
|
4
|
+
all: {
|
|
5
|
+
'&:empty': { display: 'none' },
|
|
6
|
+
},
|
|
7
|
+
first: { marginTop: 0 },
|
|
8
|
+
last: { marginBottom: 0 },
|
|
9
|
+
paragraph: {
|
|
10
|
+
marginBottom: '1em',
|
|
11
|
+
wordBreak: 'break-word',
|
|
12
|
+
},
|
|
13
|
+
h1: { marginTop: '0.5em', marginBottom: '0.5em' },
|
|
14
|
+
h2: { marginTop: '0.5em', marginBottom: '0.5em' },
|
|
15
|
+
h3: { marginTop: '0.5em', marginBottom: '0.5em' },
|
|
16
|
+
h4: { marginTop: '0.5em', marginBottom: '0.5em' },
|
|
17
|
+
h5: { marginTop: '0.5em', marginBottom: '0.5em' },
|
|
18
|
+
h6: { marginTop: '0.5em', marginBottom: '0.5em' },
|
|
19
|
+
blockquote: (theme) => ({
|
|
20
|
+
paddingLeft: theme.spacings.sm,
|
|
21
|
+
margin: `${theme.spacings.md} 0`,
|
|
22
|
+
}),
|
|
23
|
+
bullet_list: { marginBottom: '1em' },
|
|
24
|
+
ordered_list: { marginBottom: '1em' },
|
|
25
|
+
image: {
|
|
26
|
+
display: 'block',
|
|
27
|
+
width: '100%',
|
|
28
|
+
height: 'auto',
|
|
29
|
+
'& img': { display: 'block', width: '100%', height: 'auto' },
|
|
30
|
+
},
|
|
31
|
+
code_block: {
|
|
32
|
+
width: 'fit-content',
|
|
33
|
+
maxWidth: '100%',
|
|
34
|
+
padding: 5,
|
|
35
|
+
overflow: 'auto',
|
|
36
|
+
},
|
|
37
|
+
code: {
|
|
38
|
+
padding: '0.1em 0.3em',
|
|
39
|
+
borderRadius: 3,
|
|
40
|
+
fontSize: '0.9em',
|
|
41
|
+
},
|
|
42
|
+
table: (theme) => ({
|
|
43
|
+
display: 'table',
|
|
44
|
+
width: '100%',
|
|
45
|
+
borderSpacing: '2px',
|
|
46
|
+
borderCollapse: 'collapse',
|
|
47
|
+
marginTop: theme.spacings.md,
|
|
48
|
+
marginBottom: theme.spacings.sm,
|
|
49
|
+
'& thead, tbody': {
|
|
50
|
+
'& td': { padding: '10px 20px' },
|
|
51
|
+
},
|
|
52
|
+
'& thead tr td p': {
|
|
53
|
+
fontWeight: theme.typography.fontWeightBold,
|
|
54
|
+
},
|
|
55
|
+
'& tbody': {
|
|
56
|
+
display: 'table-row-group',
|
|
57
|
+
verticalAlign: 'center',
|
|
58
|
+
borderColor: 'inherit',
|
|
59
|
+
'& tr:nth-of-type(odd)': {
|
|
60
|
+
background: theme.vars.palette.background.paper,
|
|
61
|
+
},
|
|
62
|
+
'& td': {
|
|
63
|
+
[theme.breakpoints.up('sm')]: { minWidth: 150 },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
link: { wordBreak: 'break-word' },
|
|
68
|
+
underline: { textDecoration: 'underline' },
|
|
69
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { SxProps, Theme } from '@mui/material'
|
|
2
|
+
import type { ReactElement, ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
export type RichTextBlockType =
|
|
5
|
+
| 'paragraph'
|
|
6
|
+
| 'h1'
|
|
7
|
+
| 'h2'
|
|
8
|
+
| 'h3'
|
|
9
|
+
| 'h4'
|
|
10
|
+
| 'h5'
|
|
11
|
+
| 'h6'
|
|
12
|
+
| 'blockquote'
|
|
13
|
+
| 'bullet_list'
|
|
14
|
+
| 'ordered_list'
|
|
15
|
+
| 'list_item'
|
|
16
|
+
| 'code_block'
|
|
17
|
+
| 'horizontal_rule'
|
|
18
|
+
| 'hard_break'
|
|
19
|
+
| 'image'
|
|
20
|
+
| 'emoji'
|
|
21
|
+
| 'blok'
|
|
22
|
+
| 'table'
|
|
23
|
+
| 'tableRow'
|
|
24
|
+
| 'tableHeader'
|
|
25
|
+
| 'tableCell'
|
|
26
|
+
|
|
27
|
+
export type RichTextMarkType =
|
|
28
|
+
| 'bold'
|
|
29
|
+
| 'italic'
|
|
30
|
+
| 'underline'
|
|
31
|
+
| 'strike'
|
|
32
|
+
| 'code'
|
|
33
|
+
| 'link'
|
|
34
|
+
| 'anchor'
|
|
35
|
+
| 'styled'
|
|
36
|
+
| 'superscript'
|
|
37
|
+
| 'subscript'
|
|
38
|
+
| 'textStyle'
|
|
39
|
+
| 'highlight'
|
|
40
|
+
|
|
41
|
+
export type RichTextNodeType = RichTextBlockType | RichTextMarkType
|
|
42
|
+
|
|
43
|
+
type RendererBase = { sx?: SxProps<Theme>; children?: ReactNode }
|
|
44
|
+
|
|
45
|
+
export type LinkAttrs = {
|
|
46
|
+
href?: string
|
|
47
|
+
target?: string
|
|
48
|
+
anchor?: string
|
|
49
|
+
uuid?: string
|
|
50
|
+
linktype?: 'url' | 'story' | 'email' | 'asset'
|
|
51
|
+
story?: { url?: string; full_slug?: string }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ImageAttrs = {
|
|
55
|
+
src?: string
|
|
56
|
+
alt?: string
|
|
57
|
+
title?: string
|
|
58
|
+
copyright?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type EmojiAttrs = { name?: string; emoji?: string; fallbackImage?: string }
|
|
62
|
+
|
|
63
|
+
export type CodeBlockAttrs = { class?: string }
|
|
64
|
+
|
|
65
|
+
export type StyledAttrs = { class?: string }
|
|
66
|
+
|
|
67
|
+
export type TextStyleAttrs = { color?: string }
|
|
68
|
+
|
|
69
|
+
export type HighlightAttrs = { color?: string }
|
|
70
|
+
|
|
71
|
+
export type AnchorAttrs = { id?: string }
|
|
72
|
+
|
|
73
|
+
export type BlokAttrs = {
|
|
74
|
+
id?: string
|
|
75
|
+
body?: { component?: string; _uid?: string; [k: string]: unknown }[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type Renderer<Attrs = Record<string, never>> = (
|
|
79
|
+
props: RendererBase & Attrs,
|
|
80
|
+
) => ReactElement | null
|
|
81
|
+
|
|
82
|
+
export type Renderers = {
|
|
83
|
+
paragraph: Renderer
|
|
84
|
+
h1: Renderer
|
|
85
|
+
h2: Renderer
|
|
86
|
+
h3: Renderer
|
|
87
|
+
h4: Renderer
|
|
88
|
+
h5: Renderer
|
|
89
|
+
h6: Renderer
|
|
90
|
+
blockquote: Renderer
|
|
91
|
+
bullet_list: Renderer
|
|
92
|
+
ordered_list: Renderer
|
|
93
|
+
list_item: Renderer
|
|
94
|
+
code_block: Renderer<CodeBlockAttrs>
|
|
95
|
+
horizontal_rule: Renderer
|
|
96
|
+
hard_break: Renderer
|
|
97
|
+
image: Renderer<ImageAttrs>
|
|
98
|
+
emoji: Renderer<EmojiAttrs>
|
|
99
|
+
blok: Renderer<BlokAttrs>
|
|
100
|
+
table: Renderer
|
|
101
|
+
tableRow: Renderer
|
|
102
|
+
tableHeader: Renderer
|
|
103
|
+
tableCell: Renderer
|
|
104
|
+
bold: Renderer
|
|
105
|
+
italic: Renderer
|
|
106
|
+
underline: Renderer
|
|
107
|
+
strike: Renderer
|
|
108
|
+
code: Renderer
|
|
109
|
+
link: Renderer<LinkAttrs>
|
|
110
|
+
anchor: Renderer<AnchorAttrs>
|
|
111
|
+
styled: Renderer<StyledAttrs>
|
|
112
|
+
superscript: Renderer
|
|
113
|
+
subscript: Renderer
|
|
114
|
+
textStyle: Renderer<TextStyleAttrs>
|
|
115
|
+
highlight: Renderer<HighlightAttrs>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type SxRenderer = {
|
|
119
|
+
[k in RichTextNodeType | 'all' | 'first' | 'last']?: SxProps<Theme>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type AdditionalProps = {
|
|
123
|
+
renderers: Renderers
|
|
124
|
+
sxRenderer: SxRenderer
|
|
125
|
+
first?: boolean
|
|
126
|
+
last?: boolean
|
|
127
|
+
}
|
package/index.ts
ADDED
package/lib/editable.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { storyblokEditable as storyblokEditableBase, type SbBlokData } from '@storyblok/react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrapper around the upstream `storyblokEditable` that accepts auto-generated Storyblok content
|
|
5
|
+
* types. The generated types declare `[k: string]: unknown` whereas `SbBlokData` requires a
|
|
6
|
+
* narrower index signature, so a direct call wouldn't type-check; at runtime the helper only reads
|
|
7
|
+
* `_editable`, making the cast safe.
|
|
8
|
+
*/
|
|
9
|
+
export function storyblokEditable(blok: { [key: string]: unknown } | undefined | null) {
|
|
10
|
+
if (!blok) return {}
|
|
11
|
+
return storyblokEditableBase(blok as unknown as SbBlokData)
|
|
12
|
+
}
|
package/lib/fetch.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { ApolloClient } from '@graphcommerce/graphql'
|
|
2
|
+
import {
|
|
3
|
+
getStoryblokApi,
|
|
4
|
+
type ISbStoriesParams,
|
|
5
|
+
type ISbStoryData,
|
|
6
|
+
type SbBlokData,
|
|
7
|
+
} from '@storyblok/react'
|
|
8
|
+
import { resolveStoryblokProducts } from './resolveProducts'
|
|
9
|
+
|
|
10
|
+
export type StoryblokStory = ISbStoryData<SbBlokData & { body?: SbBlokData[] }>
|
|
11
|
+
|
|
12
|
+
export type FetchStoryOpts = {
|
|
13
|
+
preview?: boolean
|
|
14
|
+
locale?: string
|
|
15
|
+
defaultLocale?: string
|
|
16
|
+
/**
|
|
17
|
+
* Storyblok component-field paths whose UUID references should be hydrated
|
|
18
|
+
* into full story objects in the response (e.g. `row_button_link_list.links`).
|
|
19
|
+
*/
|
|
20
|
+
resolveRelations?: string | string[]
|
|
21
|
+
}
|
|
22
|
+
export type FetchStoriesParams = ISbStoriesParams & FetchStoryOpts
|
|
23
|
+
|
|
24
|
+
const isDev = process.env.NODE_ENV === 'development'
|
|
25
|
+
const STORIES_PER_PAGE = 100
|
|
26
|
+
const MAX_PAGE_CONCURRENCY = 3
|
|
27
|
+
const MAX_RETRIES = 3
|
|
28
|
+
|
|
29
|
+
/** Extracts the language prefix from a locale string (e.g. `en_US` → `en`). */
|
|
30
|
+
function langPrefix(locale?: string) {
|
|
31
|
+
return locale?.split(/[-_]/)[0].toLowerCase()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Default params applied to every Storyblok CDN request. */
|
|
35
|
+
export const sbParams = (opts: FetchStoryOpts = {}) => {
|
|
36
|
+
const lang = langPrefix(opts.locale)
|
|
37
|
+
const defaultLang = langPrefix(opts.defaultLocale)
|
|
38
|
+
const isDefault = !lang || lang === defaultLang
|
|
39
|
+
|
|
40
|
+
const resolveRelations = Array.isArray(opts.resolveRelations)
|
|
41
|
+
? opts.resolveRelations.join(',')
|
|
42
|
+
: opts.resolveRelations
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
version: (opts.preview || isDev ? 'draft' : 'published') as 'draft' | 'published',
|
|
46
|
+
// Hydrate `multilink` fields with the linked story's basic info (name,
|
|
47
|
+
// slug, full_slug). Lighter than `resolve_relations` because it never
|
|
48
|
+
// includes the linked story's `content.body`.
|
|
49
|
+
resolve_links: 'story' as const,
|
|
50
|
+
...(resolveRelations && { resolve_relations: resolveRelations }),
|
|
51
|
+
...(isDev && { cv: Date.now() }),
|
|
52
|
+
...(!isDefault && { language: lang }),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 404s are an expected outcome of "does this slug exist?" lookups, so they're swallowed silently.
|
|
58
|
+
* Anything else (network failure, auth, malformed response) gets logged in dev so it doesn't look
|
|
59
|
+
* indistinguishable from a missing story.
|
|
60
|
+
*/
|
|
61
|
+
function logFetchError(label: string, error: unknown) {
|
|
62
|
+
if (!isDev) return
|
|
63
|
+
const err = error as { status?: number; message?: string; response?: unknown }
|
|
64
|
+
if (err?.status === 404) return
|
|
65
|
+
console.error(`${label} failed [status=${err?.status}]:`, err?.message ?? error)
|
|
66
|
+
if (err?.response) console.error(`${label} response:`, err.response)
|
|
67
|
+
if (err?.status === 401) {
|
|
68
|
+
const api = getStoryblokApi()
|
|
69
|
+
const client = (api as unknown as { client?: { accessToken?: string; baseURL?: string } })
|
|
70
|
+
.client
|
|
71
|
+
console.error(`${label} 401 debug:`, {
|
|
72
|
+
tokenSet: Boolean(client?.accessToken),
|
|
73
|
+
tokenPrefix: client?.accessToken?.slice(0, 6),
|
|
74
|
+
tokenLength: client?.accessToken?.length,
|
|
75
|
+
baseURL: client?.baseURL,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Wraps a Storyblok request and retries on 429, honoring the Retry-After header. Other errors
|
|
82
|
+
* propagate so the caller's try/catch can decide what to do.
|
|
83
|
+
*/
|
|
84
|
+
async function fetchWithRetry<T>(request: () => Promise<T>, attempt = 0): Promise<T> {
|
|
85
|
+
try {
|
|
86
|
+
return await request()
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const status = (error as { status?: number })?.status
|
|
89
|
+
if (status !== 429 || attempt >= MAX_RETRIES) throw error
|
|
90
|
+
const headers = (error as { headers?: Record<string, string | undefined> })?.headers
|
|
91
|
+
const retryAfter = Number(headers?.['retry-after']) || 1
|
|
92
|
+
await new Promise((resolve) => {
|
|
93
|
+
setTimeout(resolve, retryAfter * 1000)
|
|
94
|
+
})
|
|
95
|
+
return fetchWithRetry(request, attempt + 1)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function storiesRequest(params: FetchStoriesParams, page: number, perPage: number) {
|
|
100
|
+
const { preview, locale, defaultLocale, ...storyblokParams } = params
|
|
101
|
+
return fetchWithRetry(() =>
|
|
102
|
+
getStoryblokApi().get('cdn/stories', {
|
|
103
|
+
...sbParams({ preview, locale, defaultLocale }),
|
|
104
|
+
...storyblokParams,
|
|
105
|
+
per_page: perPage,
|
|
106
|
+
page,
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Fetch a single page of stories from the Storyblok CDN and expose the response meta so callers can
|
|
113
|
+
* render pagination controls. Use this for listing screens.
|
|
114
|
+
*/
|
|
115
|
+
export async function fetchStories(
|
|
116
|
+
params: FetchStoriesParams,
|
|
117
|
+
): Promise<{ stories: StoryblokStory[]; total: number; perPage: number }> {
|
|
118
|
+
const perPage = params.per_page ?? STORIES_PER_PAGE
|
|
119
|
+
const page = params.page ?? 1
|
|
120
|
+
try {
|
|
121
|
+
const response = await storiesRequest(params, page, perPage)
|
|
122
|
+
return {
|
|
123
|
+
stories: response.data?.stories ?? [],
|
|
124
|
+
total: response.total ?? 0,
|
|
125
|
+
perPage,
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
logFetchError(`fetchStories(${JSON.stringify(params)})`, error)
|
|
129
|
+
return { stories: [], total: 0, perPage }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Fetch every story matching the given params by auto-paginating through all pages with bounded
|
|
135
|
+
* concurrency. Use this for getStaticPaths-style "give me every slug" queries where pagination
|
|
136
|
+
* controls aren't needed.
|
|
137
|
+
*/
|
|
138
|
+
export async function fetchAllStories(params: FetchStoriesParams): Promise<StoryblokStory[]> {
|
|
139
|
+
const perPage = params.per_page ?? STORIES_PER_PAGE
|
|
140
|
+
try {
|
|
141
|
+
const first = await storiesRequest(params, 1, perPage)
|
|
142
|
+
const stories: StoryblokStory[] = first.data?.stories ?? []
|
|
143
|
+
const totalPages = Math.ceil((first.total ?? stories.length) / perPage)
|
|
144
|
+
if (totalPages <= 1) return stories
|
|
145
|
+
|
|
146
|
+
const remaining = Array.from({ length: totalPages - 1 }, (_, i) => i + 2)
|
|
147
|
+
for (let i = 0; i < remaining.length; i += MAX_PAGE_CONCURRENCY) {
|
|
148
|
+
const chunk = remaining.slice(i, i + MAX_PAGE_CONCURRENCY)
|
|
149
|
+
const results = await Promise.all(chunk.map((p) => storiesRequest(params, p, perPage)))
|
|
150
|
+
for (const r of results) stories.push(...(r.data?.stories ?? []))
|
|
151
|
+
}
|
|
152
|
+
return stories
|
|
153
|
+
} catch (error) {
|
|
154
|
+
logFetchError(`fetchAllStories(${JSON.stringify(params)})`, error)
|
|
155
|
+
return []
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Fetch a single story by slug. When `apolloClient` is provided, resolves product data for
|
|
161
|
+
* row_product bloks.
|
|
162
|
+
*/
|
|
163
|
+
export async function fetchStory(
|
|
164
|
+
slug: string,
|
|
165
|
+
opts?: FetchStoryOpts,
|
|
166
|
+
apolloClient?: ApolloClient,
|
|
167
|
+
): Promise<{ data: { story: StoryblokStory } | null }> {
|
|
168
|
+
try {
|
|
169
|
+
const result = await fetchWithRetry(() =>
|
|
170
|
+
getStoryblokApi().get(`cdn/stories/${slug}`, sbParams(opts)),
|
|
171
|
+
)
|
|
172
|
+
if (apolloClient && result.data?.story?.content?.body) {
|
|
173
|
+
await resolveStoryblokProducts(result.data.story.content.body, apolloClient)
|
|
174
|
+
}
|
|
175
|
+
return result
|
|
176
|
+
} catch (error) {
|
|
177
|
+
logFetchError(`fetchStory('${slug}')`, error)
|
|
178
|
+
return { data: null }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { GetStaticPathsResult } from 'next'
|
|
2
|
+
import { fetchAllStories } from './fetch'
|
|
3
|
+
|
|
4
|
+
type FilterQuery = Record<string, Record<string, string>>
|
|
5
|
+
|
|
6
|
+
export type GetStoryblokStaticPathsOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* Storyblok component name to filter by. Defaults to `'page'` so the result
|
|
9
|
+
* mirrors how the storefront routes content stories.
|
|
10
|
+
*/
|
|
11
|
+
contentType?: string
|
|
12
|
+
/** Storyblok filter_query — see https://www.storyblok.com/docs/api/content-delivery/v2#filter-queries */
|
|
13
|
+
filterQuery?: FilterQuery
|
|
14
|
+
/** Override the page size of the underlying CDN request. */
|
|
15
|
+
pageSize?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fetch all Storyblok stories matching the given filters and return them as
|
|
20
|
+
* Next.js static paths. Used by sitemap and `getStaticPaths` for content
|
|
21
|
+
* routes.
|
|
22
|
+
*/
|
|
23
|
+
export async function getStoryblokStaticPaths(
|
|
24
|
+
locale: string,
|
|
25
|
+
options: GetStoryblokStaticPathsOptions = {},
|
|
26
|
+
): Promise<GetStaticPathsResult<{ url: string[] }>['paths']> {
|
|
27
|
+
const { contentType = 'page', filterQuery, pageSize = 100 } = options
|
|
28
|
+
|
|
29
|
+
const stories = await fetchAllStories({
|
|
30
|
+
per_page: pageSize,
|
|
31
|
+
content_type: contentType,
|
|
32
|
+
...(filterQuery && { filter_query: filterQuery }),
|
|
33
|
+
locale,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return stories
|
|
37
|
+
.filter((story) => Boolean(story.full_slug))
|
|
38
|
+
.map((story) => ({ params: { url: story.full_slug.split('/') }, locale }))
|
|
39
|
+
}
|
package/lib/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storyblok `multilink` field shape, covering the properties we read across
|
|
3
|
+
* all link types (story / url / email / asset). All fields are optional
|
|
4
|
+
* because unset multilinks may serialize as an empty object.
|
|
5
|
+
*/
|
|
6
|
+
export type StoryblokMultilink = {
|
|
7
|
+
id?: string
|
|
8
|
+
url?: string
|
|
9
|
+
email?: string
|
|
10
|
+
cached_url?: string
|
|
11
|
+
linktype?: 'story' | 'url' | 'email' | 'asset'
|
|
12
|
+
story?: { name?: string; full_slug?: string; uuid?: string }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extracts a usable href from a Storyblok `multilink` field. Accepts
|
|
17
|
+
* `cached_url` first (the editor-friendly short path), then `url`, then
|
|
18
|
+
* `email` (prefixed with `mailto:` if not already). Returns an empty string
|
|
19
|
+
* for unset or empty links so callers can pass it straight to `href` without
|
|
20
|
+
* a nullish check.
|
|
21
|
+
*/
|
|
22
|
+
export function multilinkHref(link?: StoryblokMultilink | null): string {
|
|
23
|
+
if (!link) return ''
|
|
24
|
+
if (link.cached_url) return link.cached_url
|
|
25
|
+
if (link.url) return link.url
|
|
26
|
+
if (link.email) return link.email.startsWith('mailto:') ? link.email : `mailto:${link.email}`
|
|
27
|
+
return ''
|
|
28
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ApolloClient } from '@graphcommerce/graphql'
|
|
2
|
+
import { ProductListDocument, type ProductListItemsFragment } from '@graphcommerce/magento-product'
|
|
3
|
+
import type { SbBlokData } from '@storyblok/react'
|
|
4
|
+
|
|
5
|
+
type RowProductBlok = SbBlokData & {
|
|
6
|
+
component: 'row_product'
|
|
7
|
+
magento_product_skus?: string
|
|
8
|
+
magento_category_id?: string
|
|
9
|
+
items?: ProductListItemsFragment['items']
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function findRowProductBloks(bloks: SbBlokData[]): RowProductBlok[] {
|
|
13
|
+
const results: RowProductBlok[] = []
|
|
14
|
+
for (const blok of bloks) {
|
|
15
|
+
if (blok.component === 'row_product') {
|
|
16
|
+
results.push(blok as RowProductBlok)
|
|
17
|
+
}
|
|
18
|
+
for (const value of Object.values(blok)) {
|
|
19
|
+
if (Array.isArray(value) && value.length > 0 && value[0]?._uid) {
|
|
20
|
+
results.push(...findRowProductBloks(value as SbBlokData[]))
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return results
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Mutates row_product bloks in-place, attaching fetched product `items` directly onto each blok. */
|
|
28
|
+
export async function resolveStoryblokProducts(
|
|
29
|
+
body: SbBlokData[] | undefined,
|
|
30
|
+
staticClient: ApolloClient,
|
|
31
|
+
) {
|
|
32
|
+
if (!body) return
|
|
33
|
+
|
|
34
|
+
const bloks = findRowProductBloks(body)
|
|
35
|
+
if (bloks.length === 0) return
|
|
36
|
+
|
|
37
|
+
const skusByBlok = new Map<RowProductBlok, string[]>()
|
|
38
|
+
const allSkus = new Set<string>()
|
|
39
|
+
|
|
40
|
+
const categoryByBlok = new Map<RowProductBlok, string>()
|
|
41
|
+
const allCategoryIds = new Set<string>()
|
|
42
|
+
|
|
43
|
+
for (const blok of bloks) {
|
|
44
|
+
const skus = blok.magento_product_skus
|
|
45
|
+
?.split(',')
|
|
46
|
+
.map((s) => s.trim())
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
|
|
49
|
+
if (skus?.length) {
|
|
50
|
+
skusByBlok.set(blok, skus)
|
|
51
|
+
skus.forEach((sku) => allSkus.add(sku))
|
|
52
|
+
} else if (blok.magento_category_id) {
|
|
53
|
+
categoryByBlok.set(blok, blok.magento_category_id)
|
|
54
|
+
allCategoryIds.add(blok.magento_category_id)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const skuItemsMap = new Map<string, NonNullable<ProductListItemsFragment['items']>[number]>()
|
|
59
|
+
if (allSkus.size > 0) {
|
|
60
|
+
const { data } = await staticClient.query({
|
|
61
|
+
query: ProductListDocument,
|
|
62
|
+
variables: { onlyItems: true, filters: { sku: { in: [...allSkus] } } },
|
|
63
|
+
})
|
|
64
|
+
for (const item of data?.products?.items ?? []) {
|
|
65
|
+
if (item?.sku) skuItemsMap.set(item.sku, item)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const categoryItemsMap = new Map<string, ProductListItemsFragment['items']>()
|
|
70
|
+
await Promise.all(
|
|
71
|
+
[...allCategoryIds].map(async (categoryId) => {
|
|
72
|
+
const { data } = await staticClient.query({
|
|
73
|
+
query: ProductListDocument,
|
|
74
|
+
variables: { onlyItems: true, filters: { category_uid: { eq: categoryId } } },
|
|
75
|
+
})
|
|
76
|
+
categoryItemsMap.set(categoryId, data?.products?.items ?? [])
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
for (const [blok, skus] of skusByBlok) {
|
|
81
|
+
blok.items = skus
|
|
82
|
+
.map((sku) => skuItemsMap.get(sku))
|
|
83
|
+
.filter((item): item is NonNullable<typeof item> => item != null)
|
|
84
|
+
}
|
|
85
|
+
for (const [blok, categoryId] of categoryByBlok) {
|
|
86
|
+
blok.items = categoryItemsMap.get(categoryId) ?? []
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prevents link navigation while the page is loaded inside the Storyblok
|
|
5
|
+
* Visual Editor iframe (detected via the `_storyblok` query param). Editors
|
|
6
|
+
* expect a click on a blok to open the field editor, not follow the link.
|
|
7
|
+
*
|
|
8
|
+
* Storyblok's own `preventClicks` bridge option is broken upstream
|
|
9
|
+
* (storyblok/monoblok#82), hence this capture-phase workaround.
|
|
10
|
+
*
|
|
11
|
+
* Call this once from a component that renders on every page (e.g. a provider
|
|
12
|
+
* that wraps your app) so pages that don't use `useStoryblokState` — such as
|
|
13
|
+
* checkout or the global config page — are also covered.
|
|
14
|
+
*/
|
|
15
|
+
export function usePreventEditorNavigation() {
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (typeof window === 'undefined') return undefined
|
|
18
|
+
if (!new URLSearchParams(window.location.search).has('_storyblok')) return undefined
|
|
19
|
+
|
|
20
|
+
const onClick = (event: MouseEvent) => {
|
|
21
|
+
const target = event.target as HTMLElement | null
|
|
22
|
+
if (target?.closest('a')) event.preventDefault()
|
|
23
|
+
}
|
|
24
|
+
document.addEventListener('click', onClick, { capture: true })
|
|
25
|
+
return () => document.removeEventListener('click', onClick, { capture: true })
|
|
26
|
+
}, [])
|
|
27
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useApolloClient } from '@graphcommerce/graphql'
|
|
2
|
+
import {
|
|
3
|
+
useStoryblokState as useStoryblokStateBase,
|
|
4
|
+
type ISbStoryData,
|
|
5
|
+
type SbBlokData,
|
|
6
|
+
} from '@storyblok/react'
|
|
7
|
+
import { useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { resolveStoryblokProducts } from './resolveProducts'
|
|
9
|
+
|
|
10
|
+
export type UseStoryblokStateOptions = {
|
|
11
|
+
/**
|
|
12
|
+
* Skip the bridge subscription and client-side product resolution. Pass
|
|
13
|
+
* `true` outside the Storyblok Visual Editor — `initialStory` is already
|
|
14
|
+
* fully resolved by `fetchStory` in `getStaticProps`.
|
|
15
|
+
*/
|
|
16
|
+
skip?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Wraps `useStoryblokState` and resolves product data for `row_product` bloks client-side. This
|
|
21
|
+
* enables live product previews in the Storyblok visual editor when `magento_product_skus` or
|
|
22
|
+
* `magento_category_id` fields are changed.
|
|
23
|
+
*
|
|
24
|
+
* The generic `T` lets the caller narrow the returned `content` to an auto-generated Storyblok
|
|
25
|
+
* content type. The runtime check guards against feeding in a story whose content isn't a `page`.
|
|
26
|
+
*/
|
|
27
|
+
export function useStoryblokState<T = SbBlokData>(
|
|
28
|
+
initialStory: ISbStoryData | null,
|
|
29
|
+
options: UseStoryblokStateOptions = {},
|
|
30
|
+
): ISbStoryData<T> | null {
|
|
31
|
+
const { skip = false } = options
|
|
32
|
+
const story = useStoryblokStateBase(initialStory, { resolveLinks: 'story' })
|
|
33
|
+
const client = useApolloClient()
|
|
34
|
+
const [resolvedStory, setResolvedStory] = useState(story)
|
|
35
|
+
const prevStoryRef = useRef(story)
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (skip) return
|
|
39
|
+
if (prevStoryRef.current === story) return
|
|
40
|
+
prevStoryRef.current = story
|
|
41
|
+
|
|
42
|
+
const body = (story?.content as { body?: unknown[] } | undefined)?.body
|
|
43
|
+
if (!body) {
|
|
44
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
45
|
+
setResolvedStory(story)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cloned = JSON.parse(JSON.stringify(story)) as typeof story
|
|
50
|
+
resolveStoryblokProducts((cloned!.content as { body: never }).body, client).then(() =>
|
|
51
|
+
setResolvedStory(cloned),
|
|
52
|
+
)
|
|
53
|
+
}, [skip, story, client])
|
|
54
|
+
|
|
55
|
+
const finalStory = skip ? initialStory : resolvedStory
|
|
56
|
+
|
|
57
|
+
if (!finalStory) return null
|
|
58
|
+
if (finalStory.content?.component === 'page') {
|
|
59
|
+
return finalStory as ISbStoryData<T>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (process.env.NODE_ENV === 'development') {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`useStoryblokState: expected story content of type 'page' but got '${finalStory.content?.component}' for slug '${finalStory.full_slug}'. Use the upstream @storyblok/react useStoryblokState for non-page content.`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@graphcommerce/storyblok-ui",
|
|
3
|
+
"homepage": "https://www.graphcommerce.org/",
|
|
4
|
+
"repository": "github:graphcommerce-org/graphcommerce",
|
|
5
|
+
"version": "10.1.0-canary.4",
|
|
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/eslint-config-pwa": "^10.1.0-canary.4",
|
|
16
|
+
"@graphcommerce/graphql": "^10.1.0-canary.4",
|
|
17
|
+
"@graphcommerce/image": "^10.1.0-canary.4",
|
|
18
|
+
"@graphcommerce/magento-product": "^10.1.0-canary.4",
|
|
19
|
+
"@graphcommerce/next-ui": "^10.1.0-canary.4",
|
|
20
|
+
"@graphcommerce/prettier-config-pwa": "^10.1.0-canary.4",
|
|
21
|
+
"@graphcommerce/typescript-config-pwa": "^10.1.0-canary.4",
|
|
22
|
+
"@mui/material": "^7.0.0",
|
|
23
|
+
"@storyblok/react": "^6.0.0",
|
|
24
|
+
"react": "^19.2.0",
|
|
25
|
+
"react-dom": "^19.2.0"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./index.ts"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/tsconfig.json
ADDED
package/types.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storyblok asset data. Matches the structure generated by the Storyblok CLI
|
|
3
|
+
* but defined here so the package is independent of generated types.
|
|
4
|
+
*
|
|
5
|
+
* Dimensions and asset type are parsed from the `filename` URL at render time
|
|
6
|
+
* since Storyblok encodes them in the URL path
|
|
7
|
+
* (`//a.storyblok.com/f/{space}/{width}x{height}/{hash}/{file}`).
|
|
8
|
+
*/
|
|
9
|
+
export interface StoryblokAssetData {
|
|
10
|
+
alt?: string | null
|
|
11
|
+
copyright?: string | null
|
|
12
|
+
fieldtype?: 'asset'
|
|
13
|
+
id?: number
|
|
14
|
+
filename: string | null
|
|
15
|
+
name?: string
|
|
16
|
+
title?: string | null
|
|
17
|
+
focus?: string | null
|
|
18
|
+
meta_data?: Record<string, unknown>
|
|
19
|
+
source?: string | null
|
|
20
|
+
is_external_url?: boolean
|
|
21
|
+
is_private?: boolean
|
|
22
|
+
src?: string
|
|
23
|
+
updated_at?: string
|
|
24
|
+
width?: number | null
|
|
25
|
+
height?: number | null
|
|
26
|
+
aspect_ratio?: number | null
|
|
27
|
+
public_id?: string | null
|
|
28
|
+
content_type?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Storyblok rich text node. Matches the structure generated by the Storyblok
|
|
33
|
+
* CLI and the Storyblok rich text field output.
|
|
34
|
+
*/
|
|
35
|
+
export interface StoryblokRichtextData {
|
|
36
|
+
type: string
|
|
37
|
+
content?: StoryblokRichtextData[]
|
|
38
|
+
marks?: StoryblokRichtextData[]
|
|
39
|
+
attrs?: Record<string, unknown>
|
|
40
|
+
text?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Utility type that strips the `[k: string]: unknown` index signature from
|
|
45
|
+
* Storyblok CLI-generated component types, giving you clean property access.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* import type { StoryblokRowHeroBanner } from '.storyblok/types/…/storyblok-components'
|
|
50
|
+
* type CleanHeroBanner = StoryblokBlokProps<StoryblokRowHeroBanner>
|
|
51
|
+
* // { asset?: StoryblokAsset; copy?: StoryblokRichtext; … }
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export type StoryblokBlokProps<T> = {
|
|
55
|
+
[K in keyof T as string extends K ? never : K]: T[K]
|
|
56
|
+
}
|
package/utils.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogg', 'mov', 'avi'])
|
|
2
|
+
export const SVG_EXTENSIONS = new Set(['svg'])
|
|
3
|
+
|
|
4
|
+
export function getExtension(filename: string): string {
|
|
5
|
+
const clean = filename.split('?')[0].split('#')[0]
|
|
6
|
+
const dot = clean.lastIndexOf('.')
|
|
7
|
+
return dot >= 0 ? clean.slice(dot + 1).toLowerCase() : ''
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Storyblok encodes image dimensions in the asset URL:
|
|
12
|
+
* `//a.storyblok.com/f/{space}/{width}x{height}/{hash}/{filename}`
|
|
13
|
+
*/
|
|
14
|
+
export function parseDimensions(filename: string): { width: number; height: number } | null {
|
|
15
|
+
const match = filename.match(/\/(\d+)x(\d+)\//)
|
|
16
|
+
if (!match) return null
|
|
17
|
+
const width = Number(match[1])
|
|
18
|
+
const height = Number(match[2])
|
|
19
|
+
return width > 0 && height > 0 ? { width, height } : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isVideo(filename: string): boolean {
|
|
23
|
+
return VIDEO_EXTENSIONS.has(getExtension(filename))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isSvg(filename: string): boolean {
|
|
27
|
+
return SVG_EXTENSIONS.has(getExtension(filename))
|
|
28
|
+
}
|