Skip to content

Commit

Permalink
add link resolving in rich text
Browse files Browse the repository at this point in the history
  • Loading branch information
korsvanloon committed May 28, 2024
1 parent 41eee74 commit c91c70d
Show file tree
Hide file tree
Showing 6 changed files with 236 additions and 71 deletions.
5 changes: 5 additions & 0 deletions .changeset/beige-starfishes-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@labdigital/storyblok-graphql-codegen-terraform': minor
---

Resolve links in richtext
19 changes: 19 additions & 0 deletions src/lib/object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const recursivelyModifyObjects = (
data: any,
objectModifier: (value: Record<string, any>) => Record<string, any>
): any => {
if (Array.isArray(data)) {
return data.map((value: any) =>
recursivelyModifyObjects(value, objectModifier)
)
} else if (typeof data === 'object') {
return Object.fromEntries(
Object.entries(objectModifier(data)).map(([key, value]) => [
key,
recursivelyModifyObjects(value, objectModifier),
])
)
} else {
return data
}
}
142 changes: 76 additions & 66 deletions src/lib/resolvers/linkResolvers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { FieldDefinitionNode, ObjectTypeDefinitionNode } from 'graphql'
import { isArray, typeName } from '../graphql'

export type UrlResolver = (input: string, context?: object) => string

/**
* Returns resolves for resolving StoryblokLink fields.
*
Expand Down Expand Up @@ -31,7 +33,7 @@ import { isArray, typeName } from '../graphql'
*/
export const linkResolvers = (
definitions: ObjectTypeDefinitionNode[],
slugResolver = (input: string) => input
urlResolver: UrlResolver = (input: string) => input
) =>
Object.fromEntries(
definitions
Expand All @@ -43,7 +45,7 @@ export const linkResolvers = (
?.filter(isLinkField)
?.map((field) => [
field.name.value,
linkResolver(field.name.value, slugResolver),
linkResolver(field.name.value, urlResolver),
]) ?? []
),
])
Expand Down Expand Up @@ -75,98 +77,106 @@ type StoryblokLink = {

type Link = {
uuid: string
[others: string]: any
full_slug: string
}

export type LinksContext = { links?: Link[] }

const linkResolver =
(prop: string, slugResolver: (input: string, context?: object) => string) =>
(prop: string, urlResolver: UrlResolver) =>
(
parent: any,
_args?: any,
context?: { links?: Link[] }
context?: LinksContext
): StoryblokLink | undefined => {
const link = parent[prop] as ApiLink
if (!link) {
return undefined
}

// internal story links
if (link.linktype === 'story' && link.id) {
// if a context is provided (through &resolve_links=url), we use it to resolve the link
if (context?.links) {
const fullSlug = context.links.find(
(l) => l.uuid === link.id
)?.full_slug

// if no link is found, it means it's unpublished, and we don't want to link to it
if (!fullSlug) {
return undefined
}

return {
type: 'internal',
hash: link.anchor,
newTab: link.target === '_blank', // internal links are not newTab by default
url:
slugResolver(fullSlug, context) +
(link.anchor ? `#${link.anchor}` : ''),
pathname: slugResolver(fullSlug, context),
}
return toStoryblokLink(link, urlResolver, context)
}

export const toStoryblokLink = (
link: any,
urlResolver: UrlResolver,
context?: LinksContext
): StoryblokLink | undefined => {
// internal story links
if (link.linktype === 'story' && link.id) {
// if a context is provided (through &resolve_links=url), we use it to resolve the link
if (context?.links) {
const fullSlug = context.links.find((l) => l.uuid === link.id)?.full_slug

// if no link is found, it means it's unpublished, and we don't want to link to it
if (!fullSlug) {
return undefined
}

// if there is no context, we do it through the cached_url
// this is not recommended, as it's not always accurate
return {
type: 'internal',
hash: link.anchor,
newTab: link.target === '_blank', // internal links are not newTab by default
url: link.cached_url?.startsWith('http')
? new URL(link.cached_url).pathname +
(link.anchor ? `#${link.anchor}` : '')
: link.cached_url ?? '',
pathname: link.cached_url?.startsWith('http')
? new URL(link.cached_url).pathname
: link.cached_url ?? '',
url:
urlResolver(fullSlug, context) +
(link.anchor ? `#${link.anchor}` : ''),
pathname: urlResolver(fullSlug, context),
}
}

// email links
if (link.linktype === 'email') {
return {
type: 'email',
newTab: true,
url: `mailto:${link.url}`,
pathname: `mailto:${link.url}`,
}
// if there is no context, we do it through the cached_url
// this is not recommended, as it's not always accurate
return {
type: 'internal',
hash: link.anchor,
newTab: link.target === '_blank', // internal links are not newTab by default
url: link.cached_url?.startsWith('http')
? new URL(link.cached_url).pathname +
(link.anchor ? `#${link.anchor}` : '')
: link.cached_url ?? '',
pathname: link.cached_url?.startsWith('http')
? new URL(link.cached_url).pathname
: link.cached_url ?? '',
}
}

// asset links
if (link.linktype === 'asset' && link.id) {
return {
type: 'asset',
newTab: true,
url: link.url,
pathname: link.url,
}
// email links
if (link.linktype === 'email') {
return {
type: 'email',
newTab: true,
url: `mailto:${link.url}`,
pathname: `mailto:${link.url}`,
}
}

// external absolute links
if (link.url?.startsWith('http')) {
return {
type: 'external',
url: link.url,
pathname: new URL(link.url).pathname,
hash: new URL(link.url).hash.substring(1), // remove the first '#'
newTab: link.target !== '_self', // external links are newTab by default
}
// asset links
if (link.linktype === 'asset' && link.id) {
return {
type: 'asset',
newTab: true,
url: link.url,
pathname: link.url,
}
}

// internal relative links
// external absolute links
if (link.url?.startsWith('http')) {
return {
type: 'internal',
type: 'external',
url: link.url,
pathname: new URL(link.url, 'http://dummy.com').pathname,
hash: new URL(link.url, 'http://dummy.com').hash.substring(1), // remove the first '#'
newTab: link.target === '_blank', // internal links are not newTab by default
pathname: new URL(link.url).pathname,
hash: new URL(link.url).hash.substring(1), // remove the first '#'
newTab: link.target !== '_self', // external links are newTab by default
}
}

// internal relative links
return {
type: 'internal',
url: link.url,
pathname: new URL(link.url, 'http://dummy.com').pathname,
hash: new URL(link.url, 'http://dummy.com').hash.substring(1), // remove the first '#'
newTab: link.target === '_blank', // internal links are not newTab by default
}
}
92 changes: 92 additions & 0 deletions src/lib/resolvers/richtextResolvers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,96 @@ describe('richtextResolver', () => {
const result = richtextResolver(prop)(data)
expect(result).toEqual(expected)
})

it('should resolve urls within rich text', () => {
const prop = 'content'
const data = {
component: 'rich_text',
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
text: 'This is a text with a ',
type: 'text',
},
{
text: 'link',
type: 'text',
marks: [
{
type: 'link',
attrs: {
// This URL should change:
href: '/wrong/url',
uuid: 'uuid-1234',
target: '_self',
linktype: 'story',
},
},
],
},
{
text: ' to a page',
type: 'text',
},
],
},
],
},
}

const context = {
links: [
{
uuid: 'uuid-1234',
// This is the correct path, but the prefix should be replaced by the custom url resolver:
full_slug: 'de/pages/good/path',
},
],
}

const urlResolver = (fullSlug: string) =>
'/nl/' + fullSlug.split('pages/')[1]

const expected = JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
text: 'This is a text with a ',
type: 'text',
},
{
text: 'link',
type: 'text',
marks: [
{
type: 'link',
attrs: {
// This should be the resolved URL:
href: '/nl/good/path',
uuid: 'uuid-1234',
target: '_self',
linktype: 'story',
},
},
],
},
{
text: ' to a page',
type: 'text',
},
],
},
],
})

const result = richtextResolver(prop, urlResolver)(data, {}, context)
expect(result).toEqual(expected)
})
})
47 changes: 43 additions & 4 deletions src/lib/resolvers/richtextResolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
ObjectTypeDefinitionNode,
} from 'graphql'
import { findStoryblokFieldValue, isArray, typeName } from '../graphql'
import { recursivelyModifyObjects } from '../object'
import { LinksContext, UrlResolver } from './linkResolvers'

/**
* Returns resolver structures for resolving richtext fields.
Expand All @@ -12,6 +14,8 @@ import { findStoryblokFieldValue, isArray, typeName } from '../graphql'
* This is because it does not make sense to partially retrieve the rich text json,
* so therefore you would otherwise need a very exhaustive and complete query.
*
* Furthermore, we resolve all links in the richtext to the full_slug of the linked story.
*
* @return richtext as a json serialized string.
*
* @example
Expand All @@ -28,7 +32,10 @@ import { findStoryblokFieldValue, isArray, typeName } from '../graphql'
* }
* ```
*/
export const richtextResolvers = (definitions: ObjectTypeDefinitionNode[]) =>
export const richtextResolvers = (
definitions: ObjectTypeDefinitionNode[],
urlResolver: UrlResolver = (input: string) => input
) =>
Object.fromEntries(
definitions
.filter(hasRichtextFields)
Expand All @@ -39,7 +46,7 @@ export const richtextResolvers = (definitions: ObjectTypeDefinitionNode[]) =>
?.filter(isRichtextField)
?.map((field) => [
field.name.value,
richtextResolver(field.name.value),
richtextResolver(field.name.value, urlResolver),
]) ?? []
),
])
Expand All @@ -53,5 +60,37 @@ const isRichtextField = (field: FieldDefinitionNode) =>
typeName(field.type) === 'String' &&
findStoryblokFieldValue<EnumValueNode>(field, 'format')?.value === 'richtext'

export const richtextResolver = (prop: string) => (parent: any) =>
JSON.stringify(parent[prop])
export const richtextResolver =
(prop: string, urlResolver: UrlResolver = (url: string) => url) =>
(parent: any, _args?: any, context?: LinksContext) =>
JSON.stringify(resolveUrls(parent[prop], urlResolver, context))

/**
* Replaces all hrefs in a richtext json object with link objects in the context.
*/
const resolveUrls = (
jsonData: object[],
urlResolver: UrlResolver,
context?: LinksContext
) =>
recursivelyModifyObjects(jsonData, (value) => {
if (value.type !== 'link') {
return value
}

const fullSlug = context?.links?.find(
(l) => l.uuid === value.attrs.uuid
)?.full_slug

if (!fullSlug) {
return value
}

return {
...value,
attrs: {
...value.attrs,
href: urlResolver(fullSlug, context),
},
}
})
Loading

0 comments on commit c91c70d

Please sign in to comment.