@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 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))
@@ -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,4 @@
1
+ export * from './RichText'
2
+ export * from './types'
3
+ export { defaultRenderers } from './defaultRenderers'
4
+ export { defaultSxRenderer } from './defaultSxRenderer'
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export * from './Asset'
2
+ export * from './RichText'
package/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './components'
2
+ export * from './lib'
3
+ export * from './types'
4
+ export * from './utils'
@@ -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,7 @@
1
+ export * from './editable'
2
+ export * from './fetch'
3
+ export * from './getStoryblokStaticPaths'
4
+ export * from './multilinkHref'
5
+ export * from './resolveProducts'
6
+ export * from './usePreventEditorNavigation'
7
+ export * from './useStoryblokState'
@@ -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
@@ -0,0 +1,5 @@
1
+ {
2
+ "exclude": ["**/node_modules", "**/.*/"],
3
+ "include": ["**/*.ts", "**/*.tsx"],
4
+ "extends": "@graphcommerce/typescript-config-pwa/nextjs.json"
5
+ }
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
+ }